review(dewey): row 74 round 1 packet, WebUI preview behind a sign-in proxy (#1568)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
# Row 74 (#1568): WebUI preview behind a sign-in proxy, round 1
|
||||
|
||||
Owner: Dewey. Reviewers: Darkwing and Filbert. Brief:
|
||||
`docs/plans/2026-10-11_webui-preview.md`, blob `1fe76f32` (rev 487; the
|
||||
change from `59fd510f` is the out-of-scope line saying Jason starts the
|
||||
preview, and nothing in the code depends on it). Base `bc8e1ee7`. None of
|
||||
the four files changed between the base and `refactor` HEAD when the
|
||||
packet was built.
|
||||
|
||||
`build.patch` is `git diff --binary bc8e1ee7 -- packages/webui`. Apply it
|
||||
with `git apply --index` to a detached worktree of the base;
|
||||
`candidate-manifest.sha256` then checks 4/4 OK.
|
||||
|
||||
## What ships
|
||||
|
||||
`serve --public-host NAME --auth-header HEADER --allow-user ID`, all three
|
||||
or none. Off by default, and without the flags the Console behaves as
|
||||
before (`serve.test.mjs` unchanged and green, plus a test that NAME is
|
||||
refused and a POST still reaches the board).
|
||||
|
||||
- `previewOptions` (serve.mjs) validates and lowercases NAME and HEADER.
|
||||
NAME is a DNS name with at least one dot: no scheme, port, path or IP
|
||||
literal, and not `localhost`. HEADER is a header name, 1 to 64 of
|
||||
`[a-z0-9-]`, not `host` or `origin`. ID is 1 to 256 visible ASCII
|
||||
characters without a comma. A partial set throws. `startServer`
|
||||
revalidates its `preview` option the same way, so a direct caller can't
|
||||
skip the check, and `preview: {}` refuses instead of serving without it.
|
||||
- The CLI collects the three flags and calls `previewOptions` before any
|
||||
config is read, so a partial or bad set exits 2 with `refused: …` and
|
||||
prints nothing on stdout. With all three it prints a `Preview:` line.
|
||||
- A request is a NAME request when its raw Host, lowercased, equals NAME
|
||||
exactly. Anything else goes through the existing loopback Host check
|
||||
unchanged, so `NAME:443` and `NAME.evil.example` get the existing 403.
|
||||
- The Origin, when sent, must be `https://NAME` for a NAME request and the
|
||||
existing `http://<Host>` for a loopback one.
|
||||
- A NAME request must carry HEADER exactly once
|
||||
(`req.headersDistinct`), with a value equal to ID. Otherwise 401
|
||||
`{error: 'sign-in required'}`, before routing, so no bus read or board
|
||||
call is made. Node lowercases header names and trims surrounding
|
||||
whitespace from values; the compare is otherwise exact.
|
||||
- In preview mode every method other than GET and HEAD answers 403
|
||||
`{error: 'read-only', message: 'the Console preview is read-only'}`,
|
||||
whatever the Host, after the sign-in check. `/api/seen` and `/api/reply`
|
||||
never reach the board.
|
||||
- Loopback requests need no header and read as before. The GET routes are
|
||||
unchanged.
|
||||
|
||||
Order of checks for a NAME request: Host, Origin, header (401), method
|
||||
(403). An unsigned write gets 401, not 403, so nothing about the routes is
|
||||
answered before sign-in.
|
||||
|
||||
## Decisions and tradeoffs
|
||||
|
||||
1. **Every non-GET/HEAD method gets 403, not only POST.** The brief says
|
||||
"every POST". The Console's only write routes are POST today, and every
|
||||
other non-GET/HEAD method already gets 404 or 405 on the base, so the
|
||||
superset changes no allowed request. It
|
||||
means a write route added later is refused in preview mode until
|
||||
someone opens it on purpose (row 76 does, under `--send`).
|
||||
2. **A port on NAME is refused.** The README tells the proxy to preserve
|
||||
`Host: NAME`. A proxy that sends `NAME:443` fails closed with a 403
|
||||
rather than being matched loosely. If ops-01's proxy adds the port,
|
||||
the fix is in the proxy config, not here.
|
||||
3. **Repeated header is 401.** Two values could come from a client copy
|
||||
the proxy failed to strip plus the proxy's own. Refusing is the safe
|
||||
reading. A comma is refused in ID for the same reason: a proxy that
|
||||
joins values with a comma would make `ID,ID` look like one value.
|
||||
4. **HEAD is allowed** with GET. It reads nothing GET doesn't.
|
||||
5. **Provider-neutral.** No identity provider is named in code, tests or
|
||||
README. The tests use `remote-user`/`jason` and, for the first
|
||||
deployment's shape, `X-authentik-uid` with a 64-hex stand-in id. The
|
||||
README example uses `X-Example-Uid`.
|
||||
|
||||
## Where the brief and the code differ (for Sage)
|
||||
|
||||
- The superset in decision 1.
|
||||
- With row 60 landed, `/api/bus/credentials` is also readable through the
|
||||
preview by the signed-in user: credential states and dates, no tokens.
|
||||
`/api/bus/decisions` is readable today. Both are GET routes that "stay as
|
||||
they are"; I mention them because the brief lists inbox, tasks, agents
|
||||
and trails as the minimum.
|
||||
- Overlap with row 60: both change `packages/webui/src/serve.mjs` (row 60
|
||||
only the `BUS_VERBS` line) and `packages/webui/README.md`. The two
|
||||
patches apply on top of each other in either order without conflict, but
|
||||
whichever lands second has different digests for those files, so its
|
||||
manifest needs a new round.
|
||||
- A refused Reply in the page shows `read-only Delivery may be unknown;
|
||||
check the seat before sending again.` The second sentence is wrong for a
|
||||
403. It comes from `src/public/app.js`, which isn't in this row's files,
|
||||
and row 76 changes that path.
|
||||
- The brief's line references (`serve.mjs` 82, 92, 96) match the base.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `packages/webui/src/serve.mjs` | `previewOptions`, the `preview` option, the NAME, Origin, header and read-only checks |
|
||||
| `packages/webui/src/cli.mjs` | the three flags, checked before config; the `Preview:` line |
|
||||
| `packages/webui/README.md` | the preview section, the proxy contract, the local exposure; two lines of the old "do not expose" text replaced |
|
||||
| `packages/webui/tests/preview.test.mjs` | new, 9 tests |
|
||||
|
||||
The README's bus-reads exposure paragraph is untouched, to keep the
|
||||
overlap with row 60 small.
|
||||
|
||||
## Tests (`preview.test.mjs`)
|
||||
|
||||
1. Options are all three or none, and each is checked (hosts, headers,
|
||||
users; `startServer` with a partial set or `{}`).
|
||||
2. The CLI refuses a partial or invalid flag set at start: exit 2,
|
||||
`refused:`, empty stdout.
|
||||
3. The CLI with all three flags serves preview mode: the `Preview:` line,
|
||||
401 unsigned, 200 signed in, 403 for a loopback POST.
|
||||
4. 401 with nothing read: no header, another user, a suffix, a different
|
||||
header, two values, across a bus read, a trail, the board and `/`.
|
||||
5. Header name case and exact value: `X-authentik-uid` on the flag with a
|
||||
64-hex id; lower, mixed and upper names pass; an uppercased value, a
|
||||
truncated value, a suffix, or `ID,ID` gets 401.
|
||||
6. Signed-in reads of inbox, tasks, agents, a trail, the board and `/`;
|
||||
NAME in mixed case; wrong Host, `NAME:443` and `NAME.evil.example` get
|
||||
403; wrong Origins get 403.
|
||||
7. Every write gets 403 read-only from NAME and from loopback, with and
|
||||
without Origin, for both write routes, a bus route and `/`; PUT,
|
||||
DELETE, PATCH and OPTIONS too; an unsigned write gets 401; nothing
|
||||
reaches the board or bus.
|
||||
8. Loopback is unchanged in preview mode, and with the board down its
|
||||
route answers the existing 502 while the bus views answer.
|
||||
9. Without the flags NAME is refused and a POST still reaches the board.
|
||||
|
||||
## Mutants
|
||||
|
||||
22 single-site mutants in a separate worktree, run against
|
||||
`preview.test.mjs` and `serve.test.mjs` (`mutants.py`, output in
|
||||
`mutants.txt`). **22 of 22
|
||||
killed**; the worktree was clean after the run.
|
||||
|
||||
Partial set allowed; NAME not lowercased; HEADER not lowercased; IP
|
||||
allowed; `host` allowed as HEADER; comma allowed in ID; `preview: {}`
|
||||
served; NAME compared case-sensitively; NAME as a prefix; NAME with any
|
||||
port; `http://NAME` Origin allowed; `https://NAME` Origin allowed on
|
||||
loopback; header check skipped; any of several values accepted; value as
|
||||
a prefix; value compared without case; header looked up by the raw flag;
|
||||
read-only only for NAME; read-only only for POST; read-only without the
|
||||
flags; CLI drops the option; CLI never validates.
|
||||
|
||||
The first run found two faults in the tests, not the code, both fixed
|
||||
before the gate. `setup()` registered the fake board's cleanup after
|
||||
`startServer`, so a mutant that made `startServer` throw left the board
|
||||
open and the run hung instead of failing. And test 1 left open any server
|
||||
a mutant let start. Both now close what they open, and the mutants that
|
||||
had hung fail in about a second. Test 3 was added because no test started
|
||||
the CLI with all three flags, so a CLI that dropped the option would have
|
||||
passed.
|
||||
|
||||
## Demonstration
|
||||
|
||||
`demo.sh` starts the CLI in preview mode on loopback port 0 with no system
|
||||
config and a closed board port, then drives it with `curl`, showing each
|
||||
refusal and the allowed reads. `demo.txt` is its output, run on the gate
|
||||
worktree, with the server's start lines. Nothing leaves the host. With no
|
||||
bus configured the inbox read answers 503 `not-configured`, which shows it
|
||||
got past the sign-in check; tests 6 and 8 show the bus reads themselves.
|
||||
|
||||
## Gate (my run on the candidate)
|
||||
|
||||
All suites ran one after another in a detached worktree of `bc8e1ee7` with
|
||||
`build.patch` applied, `TMPDIR` in my scratch and Docker on, from
|
||||
2026-10-11T22:26:06Z to 22:35:49Z. The manifest checked 4/4 OK before the
|
||||
run, and `core.hooksPath` stayed unset.
|
||||
|
||||
| Suite | Result |
|
||||
|---|---|
|
||||
| `packages/webui` | 49 pass, 0 fail (9 new), the browser tests included |
|
||||
| `packages/control-board` | 124 pass, 0 fail |
|
||||
| `packages/cli` | 112 pass, 0 fail |
|
||||
| bus, business, conversation, discord, harness, ledger, mosaic, queue, runs, seat, tasks | all 0 fail (80, 60, 182, 178, 57, 78, 69, 150, 41, 27, 71 pass) |
|
||||
| build-tokens `--check` | exit 0 |
|
||||
| test-auth, test-conductor, test-config, test-discord, test-extension-package, test-foundation, test-queue | exit 0 (15, 17, 24, 66, 18, 44, 27 passed) |
|
||||
| test-release | 14 passed, 0 failed (Docker, nothing skipped) |
|
||||
| test-task | 98 passed, 0 failed (Docker, nothing skipped) |
|
||||
|
||||
## Questions for reviewers
|
||||
|
||||
1. Is refusing `NAME:443` (decision 2) right, or should the default port be
|
||||
accepted in case the proxy adds it?
|
||||
2. Anything in the README's proxy contract that ops-01 would need and
|
||||
doesn't have?
|
||||
@@ -0,0 +1,392 @@
|
||||
diff --git a/packages/webui/README.md b/packages/webui/README.md
|
||||
index e43118d5..ff277b76 100644
|
||||
--- a/packages/webui/README.md
|
||||
+++ b/packages/webui/README.md
|
||||
@@ -16,12 +16,63 @@ Open http://127.0.0.1:7330/. Optional `--port N` and
|
||||
`--business ID` picks the business the bus views read; without it they read
|
||||
the running bus host's business (`mosaic bus start <business>`).
|
||||
Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon,
|
||||
-installation, account, authentication or deployment is added.
|
||||
+installation, account, login or deployment is added. The preview mode below
|
||||
+checks one header a sign-in proxy sets; it signs nobody in itself.
|
||||
|
||||
Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback
|
||||
by default, rejects nonlocal Host and cross-origin requests, sends no CORS
|
||||
headers, refuses redirects and accepts only JSON object POST bodies up to 4096
|
||||
-bytes. Do not expose either unauthenticated server through a public proxy.
|
||||
+bytes. Do not put the board behind a public proxy. Put the Console behind one
|
||||
+only in the preview mode below, with a proxy that meets its contract.
|
||||
+
|
||||
+## Preview behind a sign-in proxy (row 74, #1568)
|
||||
+
|
||||
+```sh
|
||||
+node packages/webui/src/cli.mjs serve --port 7332 \
|
||||
+ --public-host NAME --auth-header HEADER --allow-user ID
|
||||
+```
|
||||
+
|
||||
+The three flags go together; one or two refuse at start, before any config
|
||||
+is read. NAME is a DNS name, no scheme or port. HEADER is a header name other
|
||||
+than Host or Origin; Node lowercases incoming header names, so it matches
|
||||
+without regard to case (`X-Example-Uid` on the flag finds `x-example-uid`).
|
||||
+ID is the one user id, 1 to 256 visible ASCII characters without a comma,
|
||||
+and the value compare is exact. The Console still binds to loopback only. It
|
||||
+names no identity provider and has no login code: the proxy signs the user
|
||||
+in and limits sign-in to the right group.
|
||||
+
|
||||
+In preview mode:
|
||||
+
|
||||
+- A request whose Host is exactly NAME must carry HEADER once, with a value
|
||||
+ equal to ID. Missing, different or repeated answers 401 `sign-in required`
|
||||
+ before any route runs, so no bus read or board call is made. An Origin,
|
||||
+ when sent, must be `https://NAME`.
|
||||
+- A request whose Host is loopback on the Console's port is checked as
|
||||
+ before and needs no HEADER, so the operator's browser on the host still
|
||||
+ works. Any other Host is 403.
|
||||
+- Every method other than GET and HEAD answers 403 `read-only`, whatever the
|
||||
+ Host. `/api/seen` and `/api/reply` never reach the board, so Seen and Reply
|
||||
+ fail in the page with that refusal.
|
||||
+- The GET routes are unchanged: the bus views, the Queue, Business and
|
||||
+ Settings reads, the board panel and the assets. If the board is down, its
|
||||
+ panel shows the existing error and the bus views still answer.
|
||||
+
|
||||
+What the proxy must do. The Console trusts HEADER only because the proxy
|
||||
+controls it:
|
||||
+
|
||||
+- It terminates TLS for NAME and is the only thing that reaches the port
|
||||
+ from off the host.
|
||||
+- It strips HEADER from every client request and sets it only after the
|
||||
+ identity provider signs the user in.
|
||||
+- It forwards only requests whose Host is NAME, and preserves that Host. A
|
||||
+ loopback Host is the operator's path and skips the header check, so a
|
||||
+ proxy that forwarded a client's `Host: 127.0.0.1:7332` would let anyone
|
||||
+ through without signing in.
|
||||
+
|
||||
+The local exposure doesn't change ("Data and boundaries"): any process on
|
||||
+the host can still connect to the port and send `Host: NAME` with HEADER and
|
||||
+ID, or just a loopback Host, and read what a reader capability reads. The
|
||||
+user id is not a secret; the sign-in at the edge is what keeps others out.
|
||||
|
||||
## Use
|
||||
|
||||
diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs
|
||||
index 64b3e7b4..5c6a5931 100644
|
||||
--- a/packages/webui/src/cli.mjs
|
||||
+++ b/packages/webui/src/cli.mjs
|
||||
@@ -1,16 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
-import { startServer, DEFAULT_BOARD } from './serve.mjs';
|
||||
+import { startServer, previewOptions, DEFAULT_BOARD } from './serve.mjs';
|
||||
import { busReader } from './bus.mjs';
|
||||
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
|
||||
import { configDir } from '../../business/src/business.mjs';
|
||||
import { consoleReads, redactor } from './reads.mjs';
|
||||
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
const args = process.argv.slice(2);
|
||||
-const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID]';
|
||||
+const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID]';
|
||||
try {
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
||||
if (args.shift() !== 'serve') throw new Error(usage);
|
||||
- const options = { board: DEFAULT_BOARD, port: 7330 };
|
||||
+ const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {};
|
||||
while (args.length) {
|
||||
const flag = args.shift(), value = args.shift();
|
||||
if (!value) throw new Error(`missing value for ${flag}`);
|
||||
@@ -22,8 +22,13 @@ try {
|
||||
if (!ID_PATTERN.test(value)) throw new Error('business must be a business id');
|
||||
options.business = value;
|
||||
}
|
||||
+ // Row 74 (#1568): the preview mode, all three or none, checked before any config is read.
|
||||
+ else if (flag === '--public-host') preview.publicHost = value;
|
||||
+ else if (flag === '--auth-header') preview.authHeader = value;
|
||||
+ else if (flag === '--allow-user') preview.allowUser = value;
|
||||
else throw new Error(`unknown option: ${flag}`);
|
||||
}
|
||||
+ options.preview = previewOptions(preview);
|
||||
// The bus views (slice 1 S5) need the system config. Without it the
|
||||
// board still serves and the bus routes answer not-configured.
|
||||
// The Queue, Business and Settings reads (row 54) need no bus; without
|
||||
@@ -37,8 +42,9 @@ try {
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) { busLine = `Bus: not configured (${redactor({ configDir: configDir() })(err.message)})`; }
|
||||
const reads = consoleReads({ root: REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
||||
- const server = await startServer({ board: options.board, port: options.port, bus, reads });
|
||||
- console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}\nCtrl-C stops this server.`);
|
||||
+ const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview });
|
||||
+ const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, read-only` : '';
|
||||
+ console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${previewLine}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close());
|
||||
} catch (err) {
|
||||
console.error(`refused: ${err.message}`);
|
||||
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
|
||||
index 4d51942a..34c4aca4 100644
|
||||
--- a/packages/webui/src/serve.mjs
|
||||
+++ b/packages/webui/src/serve.mjs
|
||||
@@ -78,22 +78,54 @@ async function consoleRead(res, read) {
|
||||
return json(res, readStatus(code), { error: code, message: err instanceof ReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
-export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null } = {}) {
|
||||
+// Row 74 (#1568, lead decision 93): the preview mode behind the edge proxy. Off unless
|
||||
+// all three are given. The bind stays loopback; the proxy signs the user in
|
||||
+// and sets the header; the Console checks it against the one user id and serves reads only.
|
||||
+// Nothing here names an identity provider: one header name and one user id, from the flags.
|
||||
+const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
||||
+const HEADER = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
+const USER = /^[\x21-\x2b\x2d-\x7e]{1,256}$/;
|
||||
+export function previewOptions({ publicHost, authHeader, allowUser } = {}) {
|
||||
+ const given = [publicHost, authHeader, allowUser].filter(v => v !== undefined && v !== null);
|
||||
+ if (!given.length) return null;
|
||||
+ if (given.length !== 3) throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
|
||||
+ const host = String(publicHost).toLowerCase(), header = String(authHeader).toLowerCase();
|
||||
+ if (!HOSTNAME.test(host) || isIP(host)) throw new Error('public host must be a DNS name without scheme or port');
|
||||
+ if (!HEADER.test(header) || ['host', 'origin'].includes(header)) throw new Error('auth header must be a header name other than Host or Origin');
|
||||
+ if (typeof allowUser !== 'string' || !USER.test(allowUser)) throw new Error('allowed user must be 1..256 visible ASCII characters without a comma');
|
||||
+ return { host, header, user: allowUser };
|
||||
+}
|
||||
+export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
const upstream = boardURL(board);
|
||||
+ // An object with none of the three is refused too: asking for the preview never serves without it.
|
||||
+ if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
|
||||
+ throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
|
||||
+ }
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
let path, search;
|
||||
+ // Preview mode: a request for the public name, exactly, as the proxy forwards it.
|
||||
+ const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host;
|
||||
try {
|
||||
- const authority = new URL(`http://${req.headers.host}`);
|
||||
- if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' });
|
||||
+ if (!named) {
|
||||
+ const authority = new URL(`http://${req.headers.host}`);
|
||||
+ if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' });
|
||||
+ }
|
||||
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
|
||||
} catch { return json(res, 400, { error: 'invalid URL' }); }
|
||||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||||
- if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
+ if (req.headers.origin && req.headers.origin !== (named ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
+ if (named) {
|
||||
+ // One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
|
||||
+ const values = req.headersDistinct[preview.header];
|
||||
+ if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||
+ }
|
||||
+ // Nothing but GET and HEAD gets past here in preview mode, whatever the Host.
|
||||
+ if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
|
||||
const conversation = path === '/api/conversations' || path === '/api/conversation';
|
||||
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
|
||||
diff --git a/packages/webui/tests/preview.test.mjs b/packages/webui/tests/preview.test.mjs
|
||||
new file mode 100644
|
||||
index 00000000..97abba89
|
||||
--- /dev/null
|
||||
+++ b/packages/webui/tests/preview.test.mjs
|
||||
@@ -0,0 +1,203 @@
|
||||
+// Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the
|
||||
+// proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing
|
||||
+// but GET and HEAD gets through, whatever the Host; loopback requests work as they did.
|
||||
+import { test } from 'node:test';
|
||||
+import assert from 'node:assert/strict';
|
||||
+import { createServer, request } from 'node:http';
|
||||
+import { spawn, spawnSync } from 'node:child_process';
|
||||
+import { fileURLToPath } from 'node:url';
|
||||
+import { startServer, previewOptions } from '../src/serve.mjs';
|
||||
+import { close } from './fixture.mjs';
|
||||
+
|
||||
+const NAME = 'mosaic-preview.example.com', HEADER = 'remote-user', ID = 'jason';
|
||||
+const preview = { host: NAME, header: HEADER, user: ID };
|
||||
+const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
+
|
||||
+// node:http, not fetch: fetch can't set Host. The length is explicit because node sends a
|
||||
+// DELETE or OPTIONS body without one, which breaks the next request on a kept-alive socket.
|
||||
+function send(port, { method = 'GET', path = '/api/bus/inbox', headers = {}, body } = {}) {
|
||||
+ return new Promise((resolve, reject) => {
|
||||
+ const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
||||
+ const req = request({ host: '127.0.0.1', port, method, path, headers: { host: NAME, ...length, ...headers } }, res => {
|
||||
+ let text = '';
|
||||
+ res.on('data', c => (text += c));
|
||||
+ res.on('end', () => resolve({ status: res.statusCode, body: (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
|
||||
+ });
|
||||
+ req.on('error', reject);
|
||||
+ req.end(body);
|
||||
+ });
|
||||
+}
|
||||
+const signedIn = { [HEADER]: ID };
|
||||
+const json = { 'content-type': 'application/json' };
|
||||
+
|
||||
+// A board that counts what reaches it, and a bus that counts its reads.
|
||||
+async function setup(t, options = {}) {
|
||||
+ const boardHits = [];
|
||||
+ const upstream = createServer((req, res) => {
|
||||
+ boardHits.push(`${req.method} ${req.url}`);
|
||||
+ res.writeHead(200, json);
|
||||
+ res.end(req.url === '/api/board' ? '{"sessions":[]}' : '{"ok":true}');
|
||||
+ });
|
||||
+ await new Promise(r => upstream.listen(0, '127.0.0.1', r));
|
||||
+ // Closed first and on its own, so a refused startServer doesn't leave it holding the process open.
|
||||
+ t.after(() => close(upstream));
|
||||
+ const busCalls = [];
|
||||
+ const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }]));
|
||||
+ const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, ...options });
|
||||
+ t.after(() => close(web));
|
||||
+ return { port: web.address().port, boardHits, busCalls };
|
||||
+}
|
||||
+
|
||||
+// startServer must refuse; a server it starts anyway is closed so the failure can't hang the run.
|
||||
+async function refused(options) {
|
||||
+ const result = await startServer({ port: 0, preview: options }).catch(err => err);
|
||||
+ if (!(result instanceof Error)) { await close(result); assert.fail(`served with ${JSON.stringify(options)}`); }
|
||||
+ assert.match(result.message, /together/);
|
||||
+}
|
||||
+
|
||||
+test('preview options are all three or none, and each is checked', async () => {
|
||||
+ assert.equal(previewOptions({}), null);
|
||||
+ assert.deepEqual(previewOptions({ publicHost: 'Mosaic-Preview.Example.com', authHeader: 'Remote-User', allowUser: ID }), preview);
|
||||
+ for (const partial of [{ publicHost: NAME }, { authHeader: HEADER }, { allowUser: ID }, { publicHost: NAME, authHeader: HEADER }, { publicHost: NAME, allowUser: ID }, { authHeader: HEADER, allowUser: ID }]) {
|
||||
+ assert.throws(() => previewOptions(partial), /all|together/, JSON.stringify(partial));
|
||||
+ await refused({ host: partial.publicHost, header: partial.authHeader, user: partial.allowUser });
|
||||
+ }
|
||||
+ await refused({});
|
||||
+ for (const host of ['https://' + NAME, NAME + ':443', NAME + '/', 'localhost', '127.0.0.1', '::1', 'preview', '', 'a..b.com', '-a.example.com'])
|
||||
+ assert.throws(() => previewOptions({ publicHost: host, authHeader: HEADER, allowUser: ID }), /public host/, host);
|
||||
+ for (const header of ['host', 'Origin', 'remote user', 'x:y', '', 'a'.repeat(65)])
|
||||
+ assert.throws(() => previewOptions({ publicHost: NAME, authHeader: header, allowUser: ID }), /auth header/, header);
|
||||
+ for (const user of ['', 'jason woltje', 'a,b', 'é', 'a'.repeat(257)])
|
||||
+ assert.throws(() => previewOptions({ publicHost: NAME, authHeader: HEADER, allowUser: user }), /allowed user/, user);
|
||||
+});
|
||||
+
|
||||
+test('the CLI refuses a partial or invalid preview flag set at start', () => {
|
||||
+ const sets = [
|
||||
+ ['--public-host', NAME], ['--auth-header', HEADER], ['--allow-user', ID],
|
||||
+ ['--public-host', NAME, '--auth-header', HEADER], ['--public-host', NAME, '--allow-user', ID], ['--auth-header', HEADER, '--allow-user', ID],
|
||||
+ ['--public-host', NAME + ':443', '--auth-header', HEADER, '--allow-user', ID],
|
||||
+ ];
|
||||
+ for (const flags of sets) {
|
||||
+ const result = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...flags], { encoding: 'utf8', timeout: 10000 });
|
||||
+ assert.equal(result.status, 2, flags.join(' '));
|
||||
+ assert.match(result.stderr, /^refused: (preview mode needs|public host)/, flags.join(' '));
|
||||
+ assert.equal(result.stdout, '');
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test('the CLI with all three flags serves the preview mode', async t => {
|
||||
+ // No system config, so the bus routes answer not-configured; the gate runs before them.
|
||||
+ const env = { ...process.env, MOSAIC_CONFIG: fileURLToPath(new URL('./no-such-config.json', import.meta.url)) };
|
||||
+ const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', 'Remote-User', '--allow-user', ID], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
+ t.after(() => child.kill());
|
||||
+ let out = '';
|
||||
+ const port = await new Promise((resolve, reject) => {
|
||||
+ child.stdout.on('data', c => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); });
|
||||
+ child.on('exit', code => reject(new Error(`exited ${code}: ${out}`)));
|
||||
+ });
|
||||
+ assert.match(out, new RegExp(`^Preview: https://${NAME}/ through the proxy, signed-in user from remote-user, read-only$`, 'm'));
|
||||
+ assert.deepEqual((await send(port, {})).body, { error: 'sign-in required' });
|
||||
+ assert.equal((await send(port, { path: '/healthz', headers: signedIn })).status, 200);
|
||||
+ assert.equal((await send(port, { method: 'POST', path: '/api/seen', headers: { host: `127.0.0.1:${port}`, ...json }, body: '{}' })).status, 403);
|
||||
+});
|
||||
+
|
||||
+test('a request for the public name needs the header equal to the user id, else 401 and nothing read', async t => {
|
||||
+ const { port, boardHits, busCalls } = await setup(t);
|
||||
+ for (const [why, headers] of [
|
||||
+ ['no header', {}],
|
||||
+ ['another user', { [HEADER]: 'mallory' }],
|
||||
+ ['the id with a suffix', { [HEADER]: ID + 'x' }],
|
||||
+ ['a different header', { 'x-remote-user': ID }],
|
||||
+ ['two values', { [HEADER]: [ID, ID] }],
|
||||
+ ]) {
|
||||
+ for (const path of ['/api/bus/inbox', '/api/bus/trail?subject=row-1', '/api/board', '/']) {
|
||||
+ const r = await send(port, { path, headers });
|
||||
+ assert.equal(r.status, 401, `${why} ${path}`);
|
||||
+ assert.deepEqual(r.body, { error: 'sign-in required' });
|
||||
+ }
|
||||
+ }
|
||||
+ assert.deepEqual(busCalls, []);
|
||||
+ assert.deepEqual(boardHits, []);
|
||||
+});
|
||||
+
|
||||
+test('the header name matches without regard to case, and the value compare is exact', async t => {
|
||||
+ // The shape of the first deployment: a mixed-case header flag and a 64-hex user id.
|
||||
+ const uid = 'a3f1'.repeat(16);
|
||||
+ const { port, busCalls } = await setup(t, { preview: { host: NAME, header: 'X-authentik-uid', user: uid } });
|
||||
+ for (const name of ['x-authentik-uid', 'X-authentik-uid', 'X-AUTHENTIK-UID'])
|
||||
+ assert.equal((await send(port, { headers: { [name]: uid } })).status, 200, name);
|
||||
+ for (const value of [uid.toUpperCase(), uid.slice(1), ` ${uid}x`, uid + ',' + uid])
|
||||
+ assert.equal((await send(port, { headers: { 'x-authentik-uid': value } })).status, 401, value);
|
||||
+ assert.equal(busCalls.length, 3);
|
||||
+});
|
||||
+
|
||||
+test('the public name with the header reads the bus views, and the Host and Origin are checked first', async t => {
|
||||
+ const { port, boardHits, busCalls } = await setup(t);
|
||||
+ for (const verb of ['inbox', 'tasks', 'agents']) {
|
||||
+ const r = await send(port, { path: `/api/bus/${verb}`, headers: signedIn });
|
||||
+ assert.equal(r.status, 200, verb); assert.deepEqual(r.body.rows, [{ verb }]);
|
||||
+ }
|
||||
+ const trail = await send(port, { path: '/api/bus/trail?subject=row-1', headers: { ...signedIn, origin: `https://${NAME}` } });
|
||||
+ assert.equal(trail.status, 200);
|
||||
+ assert.equal((await send(port, { path: '/api/board', headers: signedIn })).status, 200);
|
||||
+ // A hostname compares without regard to case.
|
||||
+ assert.equal((await send(port, { path: '/healthz', headers: { ...signedIn, host: 'Mosaic-Preview.Example.COM' } })).status, 200);
|
||||
+ assert.equal((await send(port, { path: '/', headers: { 'Remote-User': ID } })).status, 200);
|
||||
+ assert.deepEqual(busCalls, [['inbox', undefined], ['tasks', undefined], ['agents', undefined], ['trail', 'row-1']]);
|
||||
+ assert.deepEqual(boardHits, ['GET /api/board']);
|
||||
+ // Wrong Host, the public name with a port, and the wrong Origin refuse even with the header.
|
||||
+ for (const headers of [{ host: 'evil.example' }, { host: NAME + ':443' }, { host: `${NAME}.evil.example` }]) {
|
||||
+ const r = await send(port, { headers: { ...signedIn, ...headers } });
|
||||
+ assert.equal(r.status, 403, headers.host); assert.deepEqual(r.body, { error: 'non-local Host refused' });
|
||||
+ }
|
||||
+ for (const origin of [`http://${NAME}`, 'https://evil.example', `https://${NAME}:443`, `http://127.0.0.1:${port}`]) {
|
||||
+ const r = await send(port, { headers: { ...signedIn, origin } });
|
||||
+ assert.equal(r.status, 403, origin); assert.deepEqual(r.body, { error: 'cross-origin request refused' });
|
||||
+ }
|
||||
+ assert.equal(busCalls.length, 4);
|
||||
+});
|
||||
+
|
||||
+test('in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing', async t => {
|
||||
+ const { port, boardHits, busCalls } = await setup(t);
|
||||
+ const loopback = { host: `127.0.0.1:${port}` };
|
||||
+ const seen = JSON.stringify({ project: 'p', agent: 'a', lastActivity: 'x', seen: true }), reply = JSON.stringify({ agent: 'p/a', text: 'hi' });
|
||||
+ for (const headers of [signedIn, loopback, { ...loopback, origin: `http://127.0.0.1:${port}` }]) {
|
||||
+ for (const [path, body] of [['/api/seen', seen], ['/api/reply', reply], ['/api/bus/inbox', '{}'], ['/', '{}']]) {
|
||||
+ const r = await send(port, { method: 'POST', path, headers: { ...headers, ...json }, body });
|
||||
+ assert.equal(r.status, 403, `${headers.host ?? NAME} POST ${path}`);
|
||||
+ assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
+ }
|
||||
+ for (const method of ['PUT', 'DELETE', 'PATCH', 'OPTIONS'])
|
||||
+ assert.equal((await send(port, { method, path: '/api/seen', headers: { ...headers, ...json }, body: seen })).status, 403, method);
|
||||
+ }
|
||||
+ // A write without the header is still a sign-in refusal first.
|
||||
+ assert.equal((await send(port, { method: 'POST', path: '/api/reply', headers: json, body: reply })).status, 401);
|
||||
+ assert.deepEqual(boardHits, []);
|
||||
+ assert.deepEqual(busCalls, []);
|
||||
+});
|
||||
+
|
||||
+test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => {
|
||||
+ const probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r));
|
||||
+ const dead = `http://127.0.0.1:${probe.address().port}`; await close(probe);
|
||||
+ const { port, busCalls } = await setup(t, { board: dead, timeout: 200 });
|
||||
+ const loopback = { host: `127.0.0.1:${port}` };
|
||||
+ const inbox = await send(port, { headers: loopback });
|
||||
+ assert.equal(inbox.status, 200); assert.deepEqual(inbox.body.rows, [{ verb: 'inbox' }]);
|
||||
+ assert.equal((await send(port, { path: '/healthz', headers: { host: `localhost:${port}` } })).status, 200);
|
||||
+ assert.equal((await send(port, { headers: { ...loopback, origin: `https://${NAME}` } })).status, 403);
|
||||
+ assert.equal((await send(port, { headers: { host: `127.0.0.1:${port + 1}` } })).status, 403);
|
||||
+ // The board panel's existing error; the bus views still answer.
|
||||
+ const board = await send(port, { path: '/api/board', headers: signedIn });
|
||||
+ assert.equal(board.status, 502); assert.equal(board.body.board, dead);
|
||||
+ assert.equal((await send(port, { path: '/api/bus/agents', headers: signedIn })).status, 200);
|
||||
+ assert.deepEqual(busCalls.map(c => c[0]), ['inbox', 'agents']);
|
||||
+});
|
||||
+
|
||||
+test('without the preview flags the public name is refused and writes still reach the board', async t => {
|
||||
+ const { port, boardHits } = await setup(t, { preview: null });
|
||||
+ const r = await send(port, { headers: signedIn });
|
||||
+ assert.equal(r.status, 403); assert.deepEqual(r.body, { error: 'non-local Host refused' });
|
||||
+ const seen = await send(port, { method: 'POST', path: '/api/seen', headers: { host: `127.0.0.1:${port}`, ...json }, body: '{"seen":true}' });
|
||||
+ assert.equal(seen.status, 200);
|
||||
+ assert.deepEqual(boardHits, ['POST /api/seen']);
|
||||
+});
|
||||
@@ -0,0 +1,4 @@
|
||||
35fb02a5bcb0625a40f7aaf80e588c7acf7de1ac4ef7986c492bb9d097c0d5e4 packages/webui/README.md
|
||||
e0970dfdb3c930b622ec40d5fb60ca3ca0d95f05f604f561a1a97b1192a84d2a packages/webui/src/cli.mjs
|
||||
fe27eaadfee4b2c1df89304f4b562420634fec948648e36d7ef3677e4d64e24f packages/webui/src/serve.mjs
|
||||
50db4c28ff6c4f299bb03cfe194404843beb0fb8166ee63929d28833833680d7 packages/webui/tests/preview.test.mjs
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Row 74 (#1568): the Console in preview mode, driven with curl on loopback only.
|
||||
# Run from a worktree with the candidate applied: bash demo.sh <worktree> <scratch>.
|
||||
# No system config (MOSAIC_CONFIG names a missing file), so the bus routes answer
|
||||
# not-configured once past the sign-in check; the board flag names a closed port.
|
||||
set -u
|
||||
TREE=$1 SCRATCH=$2
|
||||
NAME=mosaic-preview.woltje.com HEADER=X-authentik-uid
|
||||
ID=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef # a stand-in; ops-01 sends the real id
|
||||
LOG=$SCRATCH/demo-server.txt
|
||||
cd "$TREE" || exit 1
|
||||
echo "== partial flag set, refused at start"
|
||||
MOSAIC_CONFIG=$SCRATCH/no-config.json node packages/webui/src/cli.mjs serve --port 0 --public-host "$NAME" --auth-header "$HEADER"; echo "exit $?"
|
||||
MOSAIC_CONFIG=$SCRATCH/no-config.json node packages/webui/src/cli.mjs serve --port 0 \
|
||||
--board http://127.0.0.1:9 --public-host "$NAME" --auth-header "$HEADER" --allow-user "$ID" > "$LOG" 2>&1 &
|
||||
PID=$!
|
||||
trap 'kill "$PID" 2>/dev/null' EXIT
|
||||
for _ in $(seq 50); do grep -q '^Mosaic Console' "$LOG" && break; sleep 0.1; done
|
||||
cat "$LOG"
|
||||
PORT=$(sed -n 's|^Mosaic Console: http://127.0.0.1:\([0-9]*\)/$|\1|p' "$LOG")
|
||||
U=http://127.0.0.1:$PORT
|
||||
c() { local what=$1 out; shift; out=$(curl -s -w '\n%{http_code}' "$@"); printf '%-42s %s %.110s\n' "$what" "${out##*$'\n'}" "$(printf '%s' "${out%$'\n'*}" | tr -d '\n')"; }
|
||||
echo "== refusals"
|
||||
c "wrong Host" -H "Host: evil.example" -H "$HEADER: $ID" "$U/api/bus/inbox"
|
||||
c "public name with a port" -H "Host: $NAME:443" -H "$HEADER: $ID" "$U/api/bus/inbox"
|
||||
c "wrong Origin" -H "Host: $NAME" -H "$HEADER: $ID" -H "Origin: https://evil.example" "$U/api/bus/inbox"
|
||||
c "missing header" -H "Host: $NAME" "$U/api/bus/inbox"
|
||||
c "wrong user" -H "Host: $NAME" -H "$HEADER: ${ID%?}0" "$U/api/bus/inbox"
|
||||
c "POST /api/seen, signed in" -H "Host: $NAME" -H "$HEADER: $ID" -H 'Content-Type: application/json' -d '{"seen":true}' "$U/api/seen"
|
||||
c "POST /api/reply, signed in" -H "Host: $NAME" -H "$HEADER: $ID" -H 'Content-Type: application/json' -d '{"agent":"p/a","text":"x"}' "$U/api/reply"
|
||||
c "POST /api/reply, loopback Host" -H 'Content-Type: application/json' -d '{"agent":"p/a","text":"x"}' "$U/api/reply"
|
||||
echo "== allowed"
|
||||
c "GET /healthz, signed in, lowercase header" -H "Host: $NAME" -H "x-authentik-uid: $ID" -H "Origin: https://$NAME" "$U/healthz"
|
||||
c "GET /api/config, signed in" -H "Host: $NAME" -H "$HEADER: $ID" "$U/api/config"
|
||||
c "GET / (the page), signed in" -H "Host: $NAME" -H "$HEADER: $ID" "$U/"
|
||||
c "GET /api/bus/inbox, signed in (no bus)" -H "Host: $NAME" -H "$HEADER: $ID" "$U/api/bus/inbox"
|
||||
c "GET /api/board, signed in (board down)" -H "Host: $NAME" -H "$HEADER: $ID" "$U/api/board"
|
||||
c "GET /healthz, loopback, no header" "$U/healthz"
|
||||
@@ -0,0 +1,24 @@
|
||||
== partial flag set, refused at start
|
||||
refused: preview mode needs --public-host, --auth-header and --allow-user together
|
||||
exit 2
|
||||
Mosaic Console: http://127.0.0.1:44305/
|
||||
Board: http://127.0.0.1:9
|
||||
Bus: not configured (system config problem (mosaic-config exit 3): mosaic-config: configuration not found: <config>/no-config.json (run scripts/bootstrap.sh to create it))
|
||||
Preview: https://mosaic-preview.woltje.com/ through the proxy, signed-in user from x-authentik-uid, read-only
|
||||
Ctrl-C stops this server.
|
||||
== refusals
|
||||
wrong Host 403 {"error":"non-local Host refused"}
|
||||
public name with a port 403 {"error":"non-local Host refused"}
|
||||
wrong Origin 403 {"error":"cross-origin request refused"}
|
||||
missing header 401 {"error":"sign-in required"}
|
||||
wrong user 401 {"error":"sign-in required"}
|
||||
POST /api/seen, signed in 403 {"error":"read-only","message":"the Console preview is read-only"}
|
||||
POST /api/reply, signed in 403 {"error":"read-only","message":"the Console preview is read-only"}
|
||||
POST /api/reply, loopback Host 403 {"error":"read-only","message":"the Console preview is read-only"}
|
||||
== allowed
|
||||
GET /healthz, signed in, lowercase header 200 {"ok":true}
|
||||
GET /api/config, signed in 200 {"board":"http://127.0.0.1:9"}
|
||||
GET / (the page), signed in 200 <!doctype html><html lang="en" data-design="console" data-palette="harbor"><head><meta charset="utf-8"><meta n
|
||||
GET /api/bus/inbox, signed in (no bus) 503 {"error":"not-configured","message":"the Console has no bus configured"}
|
||||
GET /api/board, signed in (board down) 502 {"error":"Board unreachable or invalid response at http://127.0.0.1:9. Check the board server. No automatic ac
|
||||
GET /healthz, loopback, no header 200 {"ok":true}
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
# Dewey row 74 mutants: single-site edits in the mutant worktree (never the build tree),
|
||||
# each run against the preview and serve tests, then restored from the index.
|
||||
import os, subprocess, sys
|
||||
|
||||
R = os.path.expanduser('~/dewey-scratch/r74')
|
||||
T = R + '/mut'
|
||||
OUT = R + '/out'
|
||||
SERVE = 'packages/webui/src/serve.mjs'
|
||||
CLI = 'packages/webui/src/cli.mjs'
|
||||
TESTS = ['node', '--test', 'packages/webui/tests/preview.test.mjs', 'packages/webui/tests/serve.test.mjs']
|
||||
|
||||
MUTANTS = [
|
||||
('partial-allowed', SERVE, "if (given.length !== 3) throw", "if (given.length < 1) throw", TESTS),
|
||||
('host-not-lowercased', SERVE, "const host = String(publicHost).toLowerCase(), header", "const host = String(publicHost), header", TESTS),
|
||||
('header-not-lowercased', SERVE, "header = String(authHeader).toLowerCase();", "header = String(authHeader);", TESTS),
|
||||
('ip-allowed', SERVE, "if (!HOSTNAME.test(host) || isIP(host)) throw", "if (!HOSTNAME.test(host)) throw", TESTS),
|
||||
('host-header-allowed', SERVE, "['host', 'origin'].includes(header)", "['origin'].includes(header)", TESTS),
|
||||
('user-comma-allowed', SERVE, "const USER = /^[\\x21-\\x2b\\x2d-\\x7e]{1,256}$/;", "const USER = /^[\\x21-\\x7e]{1,256}$/;", TESTS),
|
||||
('empty-object-serves', SERVE, "if (preview !== null && !(preview = previewOptions(", "if (preview !== null && false && !(preview = previewOptions(", TESTS),
|
||||
('named-case-sensitive', SERVE, "(req.headers.host ?? '').toLowerCase() === preview.host", "(req.headers.host ?? '') === preview.host", TESTS),
|
||||
('named-prefix', SERVE, "(req.headers.host ?? '').toLowerCase() === preview.host", "(req.headers.host ?? '').toLowerCase().startsWith(preview.host)", TESTS),
|
||||
('named-hostname-only', SERVE, "(req.headers.host ?? '').toLowerCase() === preview.host", "(req.headers.host ?? '').toLowerCase().split(':')[0] === preview.host", TESTS),
|
||||
('origin-http-allowed', SERVE, "(named ? `https://${preview.host}` : `http://${req.headers.host}`)", "(named ? `http://${preview.host}` : `http://${req.headers.host}`)", TESTS),
|
||||
('origin-public-on-loopback', SERVE, "req.headers.origin !== (named ? `https://${preview.host}`", "req.headers.origin !== `https://${preview?.host}` && req.headers.origin !== (named ? `https://${preview.host}`", TESTS),
|
||||
('header-check-skipped', SERVE, " if (named) {\n // One value", " if (false) {\n // One value", TESTS),
|
||||
('two-values-allowed', SERVE, "values.length !== 1 || values[0] !== preview.user", "!values.includes(preview.user)", TESTS),
|
||||
('value-prefix', SERVE, "values[0] !== preview.user", "!values[0].startsWith(preview.user)", TESTS),
|
||||
('value-case-insensitive', SERVE, "values[0] !== preview.user", "values[0].toLowerCase() !== preview.user.toLowerCase()", TESTS),
|
||||
('header-check-raw-name', SERVE, "req.headersDistinct[preview.header]", "req.headersDistinct[preview.header.toUpperCase()]", TESTS),
|
||||
('readonly-named-only', SERVE, "if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return", "if (named && req.method !== 'GET' && req.method !== 'HEAD') return", TESTS),
|
||||
('readonly-post-only', SERVE, "if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return", "if (preview !== null && req.method === 'POST') return", TESTS),
|
||||
('readonly-always', SERVE, "if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return", "if (req.method !== 'GET' && req.method !== 'HEAD') return", TESTS),
|
||||
('cli-preview-dropped', CLI, "bus, reads, preview: options.preview });", "bus, reads });", TESTS),
|
||||
('cli-checked-late', CLI, " options.preview = previewOptions(preview);\n", "", TESTS),
|
||||
]
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
p = subprocess.run(cmd, cwd=T, capture_output=True, text=True, timeout=120, env={**os.environ, 'TMPDIR': R + '/tmp'})
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return 'timeout', str(e.stdout or '')
|
||||
return p.returncode, p.stdout + p.stderr
|
||||
|
||||
want = sys.argv[1:]
|
||||
results = []
|
||||
for name, f, old, new, tests in MUTANTS:
|
||||
if want and name not in want:
|
||||
continue
|
||||
path = os.path.join(T, f)
|
||||
src = open(path).read()
|
||||
if src.count(old) != 1:
|
||||
results.append(f'{name} BAD-SITE ({src.count(old)} matches)')
|
||||
print(results[-1], flush=True)
|
||||
continue
|
||||
open(path, 'w').write(src.replace(old, new))
|
||||
try:
|
||||
rc, out = run(tests)
|
||||
finally:
|
||||
subprocess.run(['git', 'checkout', '--', f], cwd=T, check=True)
|
||||
open(f'{OUT}/mut-{name}.txt', 'w').write(out)
|
||||
results.append(f"{name} {'killed (timeout)' if rc == 'timeout' else 'killed' if rc else 'SURVIVED'}")
|
||||
print(results[-1], flush=True)
|
||||
rc, diff = run(['git', 'diff', '--stat'])
|
||||
print('worktree diff after:', diff.strip() or 'clean')
|
||||
killed = sum(1 for r in results if ' killed' in r)
|
||||
print(f'summary {killed} of {len(results)} killed')
|
||||
@@ -0,0 +1,24 @@
|
||||
partial-allowed killed
|
||||
host-not-lowercased killed
|
||||
header-not-lowercased killed
|
||||
ip-allowed killed
|
||||
host-header-allowed killed
|
||||
user-comma-allowed killed
|
||||
empty-object-serves killed
|
||||
named-case-sensitive killed
|
||||
named-prefix killed
|
||||
named-hostname-only killed
|
||||
origin-http-allowed killed
|
||||
origin-public-on-loopback killed
|
||||
header-check-skipped killed
|
||||
two-values-allowed killed
|
||||
value-prefix killed
|
||||
value-case-insensitive killed
|
||||
header-check-raw-name killed
|
||||
readonly-named-only killed
|
||||
readonly-post-only killed
|
||||
readonly-always killed
|
||||
cli-preview-dropped killed
|
||||
cli-checked-late killed
|
||||
worktree diff after: clean
|
||||
summary 22 of 22 killed
|
||||
Reference in New Issue
Block a user