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:
2026-10-11 17:37:14 -05:00
co-authored by Claude Opus 5.5
parent 99db499483
commit ea07e19c5a
7 changed files with 735 additions and 0 deletions
+186
View File
@@ -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?
+392
View File
@@ -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
+38
View File
@@ -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"
+24
View File
@@ -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}
+67
View File
@@ -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')
+24
View File
@@ -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