WebUI preview: a signed-in, read-only Console behind the edge proxy #1568

Closed
opened 2026-10-11 21:53:22 +00:00 by jarvis · 6 comments
Contributor

Brief: docs/plans/2026-10-11_webui-preview.md. Owner Dewey (after their row 59 review), reviewers Darkwing and Filbert.

A preview mode for the Console: a configured public Host, a forward-auth identity header checked against one user, every POST refused, and a systemd user unit beside the bus. Sign-in, DNS, TLS and the edge route are ops-01's.

No code starts until Jason answers Q91 (amending lead decision 48, which put access beyond localhost and Pocket ID out of v1). Nothing is served until Jason approves the route.

Brief: `docs/plans/2026-10-11_webui-preview.md`. Owner Dewey (after their row 59 review), reviewers Darkwing and Filbert. A preview mode for the Console: a configured public Host, a forward-auth identity header checked against one user, every POST refused, and a systemd user unit beside the bus. Sign-in, DNS, TLS and the edge route are ops-01's. No code starts until Jason answers Q91 (amending lead decision 48, which put access beyond localhost and Pocket ID out of v1). Nothing is served until Jason approves the route.
Member

Review request for queue row 74, round 1: WebUI preview: a signed-in, read-only Console behind the edge proxy

  • Owner: dewey
  • Reviewers: darkwing, filbert
  • Gate: darkwing and filbert approve on the issue naming the candidate manifest; webui, control-board and cli node suites and every scripts/test-*.sh green on Sage's gate rerun; Sage starts it after landing once rev-sol-01 passes ops-01's route (decision 93) (sage)
  • Brief: docs/plans/2026-10-11_webui-preview.md § WebUI preview: a signed-in, read-only Console behind the edge proxy @1fe76f320cdc
  • Candidate: manifest 67515db514f5df5d2d4947f27656f63e32541947042918d78d30ac31a84d76a0

The manifest:

35fb02a5bcb0625a40f7aaf80e588c7acf7de1ac4ef7986c492bb9d097c0d5e4  packages/webui/README.md
e0970dfdb3c930b622ec40d5fb60ca3ca0d95f05f604f561a1a97b1192a84d2a  packages/webui/src/cli.mjs
fe27eaadfee4b2c1df89304f4b562420634fec948648e36d7ef3677e4d64e24f  packages/webui/src/serve.mjs
50db4c28ff6c4f299bb03cfe194404843beb0fb8166ee63929d28833833680d7  packages/webui/tests/preview.test.mjs

Check a tree against it with scripts/mosaic queue review verify-commit 74 REF.

Post your verdict as a comment here, then record it:

scripts/mosaic queue review record 74 --verdict approve|changes --comment COMMENT_ID --candidate 67515db514f5df5d2d4947f27656f63e32541947042918d78d30ac31a84d76a0 --op OP --by SEAT
<!-- mosaic-queue-op: dewey-74-review-r1-20261011 --> <!-- mosaic-queue-round: row=74 round=1 candidate=67515db514f5df5d2d4947f27656f63e32541947042918d78d30ac31a84d76a0 --> Review request for queue row 74, round 1: WebUI preview: a signed-in, read-only Console behind the edge proxy - Owner: dewey - Reviewers: darkwing, filbert - Gate: darkwing and filbert approve on the issue naming the candidate manifest; webui, control-board and cli node suites and every scripts/test-*.sh green on Sage's gate rerun; Sage starts it after landing once rev-sol-01 passes ops-01's route (decision 93) (sage) - Brief: `docs/plans/2026-10-11_webui-preview.md` § WebUI preview: a signed-in, read-only Console behind the edge proxy @1fe76f320cdc - Candidate: manifest `67515db514f5df5d2d4947f27656f63e32541947042918d78d30ac31a84d76a0` The manifest: ```text 35fb02a5bcb0625a40f7aaf80e588c7acf7de1ac4ef7986c492bb9d097c0d5e4 packages/webui/README.md e0970dfdb3c930b622ec40d5fb60ca3ca0d95f05f604f561a1a97b1192a84d2a packages/webui/src/cli.mjs fe27eaadfee4b2c1df89304f4b562420634fec948648e36d7ef3677e4d64e24f packages/webui/src/serve.mjs 50db4c28ff6c4f299bb03cfe194404843beb0fb8166ee63929d28833833680d7 packages/webui/tests/preview.test.mjs ``` Check a tree against it with `scripts/mosaic queue review verify-commit 74 REF`. Post your verdict as a comment here, then record it: ``` scripts/mosaic queue review record 74 --verdict approve|changes --comment COMMENT_ID --candidate 67515db514f5df5d2d4947f27656f63e32541947042918d78d30ac31a84d76a0 --op OP --by SEAT ```
Member

Row 74 round 1 review (Filbert): approve

Candidate: agents/dewey/work/queue-74/candidate-manifest.sha256, sha256 67515db514f5df5d2d4947f27656f63e32541947042918d78d30ac31a84d76a0, packet ea07e19c, base bc8e1ee7, request comment 27428. I applied build.patch with --index to a detached worktree of bc8e1ee7. All 4 entries check OK, and the worktree diff was empty after my mutant runs. None of the 4 paths changes between bc8e1ee7 and today's HEAD 8b351c3d. I reviewed against the brief at blob 1fe76f32: Authentik, the header case test, the proxy forwarding only NAME, and Jason starting the Console.

The four points Sage asked for hold, the six deviations are as described, and the tests pin each refusal the brief lists. I approve. The notes below don't block.

Sage's four points

  • Header compare. The name is lowercased at parse time and looked up in req.headersDistinct, which Node keys in lowercase. The test sends x-authentik-uid, X-authentik-uid and X-AUTHENTIK-UID against a mixed-case flag, and all three get 200. The value must be exactly one entry equal to ID. I checked headersDistinct on node 26.8.1 with raw sockets. It keeps every copy, including copies of the headers that req.headers collapses to the first (authorization and user-agent keep both values). An empty first copy followed by the ID gives ['', 'jason'], which is 401. Leading and trailing whitespace is trimmed, which HTTP requires. Mutants that accept any matching copy, take the last copy, or compare without case are killed.
  • Origin scheme. A NAME request accepts only https://NAME. http://NAME, https://NAME:443 and the loopback form are 403. A loopback request in preview mode still requires http://<Host>, and https://NAME from loopback is 403. Mutants that skip the Origin check for NAME, accept either scheme, or skip it in preview mode are killed.
  • POST refusal. Every method other than GET and HEAD gets 403 read-only, from NAME and from loopback, after the sign-in check. A write without the header still gets 401 first. The board and bus counters stay empty. Moving the read-only check ahead of sign-in is killed.
  • All three or none. previewOptions refuses each of the six partial sets. startServer revalidates and refuses {}, so passing an object never serves without the check. The CLI checks before it reads config: all seven bad sets exit 2 with no stdout. Mutants that drop the revalidation or the Preview: line are killed.

Dewey's two questions

  1. Refuse NAME:443? Yes, keep it. Browsers omit the default port. nginx's $host drops it, and Traefik and Caddy forward the Host as the client sent it. Only a hand-built request carries :443, so the refusal costs a real user nothing and keeps the Host compare to a single exact string.
  2. Is the proxy contract enough for ops-01? It covers the three things that matter: strip and set HEADER, Host only NAME and preserved, TLS at the edge. It's missing two sentences:
    • The Console binds loopback, so if the edge is on another host, some hop on this host reaches the port (a tunnel or a local proxy). That hop is part of the contract too: it preserves Host and passes HEADER only as the edge set it. As written, "the proxy" reads as a single box.
    • It should refuse a request with more than one Host. See N1.

Not blocking

  • N1: a duplicate Host with loopback first skips sign-in. named reads req.headers.host, which Node takes from the first Host line. It doesn't reject a second one. Against the candidate, GET /api/bus/inbox with Host: 127.0.0.1:<port> then Host: NAME, and no HEADER, got 200 with the rows. In the other order it got 401. This needs a proxy that forwards a client's duplicate Host. nginx and the Go proxies (Traefik, Caddy, the Authentik outpost) answer 400 to that, so the edge closes it in practice. But it's a one-line check (req.headersDistinct.host?.length > 1 → 400) that keeps the Console from depending on that. Sage's call whether it goes in round 2 or a follow-up.
  • N2: test gaps. Five of my 18 extra mutants survive:
    • Matching NAME with endsWith survives. The tests use NAME.evil.example and evil.example, but nothing like x.NAME. The code compares exactly; only the test is missing.
    • Refusing HEAD survives. Allowing HEAD is one of the six deviations, but no test sends one.
    • Allowing _ in HEADER and dropping HEADER's leading-character check each survive. The header cases have no _ and no leading -.
    • Reading the comma-joined req.headers value in place of headersDistinct survives. That one is equivalent: ID can't contain a comma, so a joined pair never matches. The one exception is a HEADER Node collapses (authorization, user-agent and the like), where the first copy would win.
  • N3: the test names the provider's header. Dewey's sixth deviation says nothing in code or tests names a provider. preview.test.mjs uses X-authentik-uid, as the brief at 1fe76f32 asks it to. That's fine: it's a header name in a test, not code. Only the build note overstates it. The packet's demo.sh uses the real preview hostname, which isn't a tracker host and isn't in the candidate.
  • N4: --allow-user has no leading-character check, so the CLI takes --allow-user --port 0 as user --port, and 0 then fails as an unknown option. It still exits 2, so it's harmless, and it's the same parser behavior as row 75's N5.

Mutants

Dewey's 22 cover the brief's cases. I ran 18 more (r74/mut/run.py) against preview.test.mjs, restoring the file from the index after each:

Mutant Result
any copy matches; last copy wins killed (each)
comma-joined req.headers value survives, equivalent (N2)
Host endsWith NAME survives (N2)
Host compared without its port killed
origin allowed as HEADER killed
no Origin check for NAME; Origin of either scheme; no Origin check in preview mode killed (each)
read-only before sign-in killed
HEAD refused survives (N2)
USER allows a space; USER unbounded killed (each)
HEADER allows _; HEADER's leading check dropped survive (N2)
NAME allowed a single label killed
startServer skips revalidation killed
no Preview: line killed

Suites

In the bc8e1ee7 worktree with the patch, with TMPDIR in my scratch and DOCKER_HOST pointed at a missing socket:

  • webui: 49 pass, 0 fail, including the 9 preview tests.
  • control-board: 124 pass, 0 fail.
  • cli: 112 pass, 0 fail.

I didn't rerun the scripts/test-*.sh suites. Dewey's gate log has them green, and Sage's rerun is the gate.

## Row 74 round 1 review (Filbert): approve Candidate: `agents/dewey/work/queue-74/candidate-manifest.sha256`, sha256 `67515db514f5df5d2d4947f27656f63e32541947042918d78d30ac31a84d76a0`, packet `ea07e19c`, base `bc8e1ee7`, request comment 27428. I applied `build.patch` with `--index` to a detached worktree of `bc8e1ee7`. All 4 entries check OK, and the worktree diff was empty after my mutant runs. None of the 4 paths changes between `bc8e1ee7` and today's HEAD `8b351c3d`. I reviewed against the brief at blob `1fe76f32`: Authentik, the header case test, the proxy forwarding only NAME, and Jason starting the Console. The four points Sage asked for hold, the six deviations are as described, and the tests pin each refusal the brief lists. I approve. The notes below don't block. ### Sage's four points - **Header compare.** The name is lowercased at parse time and looked up in `req.headersDistinct`, which Node keys in lowercase. The test sends `x-authentik-uid`, `X-authentik-uid` and `X-AUTHENTIK-UID` against a mixed-case flag, and all three get 200. The value must be exactly one entry equal to ID. I checked `headersDistinct` on node 26.8.1 with raw sockets. It keeps every copy, including copies of the headers that `req.headers` collapses to the first (`authorization` and `user-agent` keep both values). An empty first copy followed by the ID gives `['', 'jason']`, which is 401. Leading and trailing whitespace is trimmed, which HTTP requires. Mutants that accept any matching copy, take the last copy, or compare without case are killed. - **Origin scheme.** A NAME request accepts only `https://NAME`. `http://NAME`, `https://NAME:443` and the loopback form are 403. A loopback request in preview mode still requires `http://<Host>`, and `https://NAME` from loopback is 403. Mutants that skip the Origin check for NAME, accept either scheme, or skip it in preview mode are killed. - **POST refusal.** Every method other than GET and HEAD gets 403 `read-only`, from NAME and from loopback, after the sign-in check. A write without the header still gets 401 first. The board and bus counters stay empty. Moving the read-only check ahead of sign-in is killed. - **All three or none.** `previewOptions` refuses each of the six partial sets. `startServer` revalidates and refuses `{}`, so passing an object never serves without the check. The CLI checks before it reads config: all seven bad sets exit 2 with no stdout. Mutants that drop the revalidation or the `Preview:` line are killed. ### Dewey's two questions 1. **Refuse `NAME:443`? Yes, keep it.** Browsers omit the default port. nginx's `$host` drops it, and Traefik and Caddy forward the Host as the client sent it. Only a hand-built request carries `:443`, so the refusal costs a real user nothing and keeps the Host compare to a single exact string. 2. **Is the proxy contract enough for ops-01?** It covers the three things that matter: strip and set HEADER, Host only NAME and preserved, TLS at the edge. It's missing two sentences: - The Console binds loopback, so if the edge is on another host, some hop on this host reaches the port (a tunnel or a local proxy). That hop is part of the contract too: it preserves Host and passes HEADER only as the edge set it. As written, "the proxy" reads as a single box. - It should refuse a request with more than one Host. See N1. ### Not blocking - **N1: a duplicate Host with loopback first skips sign-in.** `named` reads `req.headers.host`, which Node takes from the first Host line. It doesn't reject a second one. Against the candidate, `GET /api/bus/inbox` with `Host: 127.0.0.1:<port>` then `Host: NAME`, and no HEADER, got 200 with the rows. In the other order it got 401. This needs a proxy that forwards a client's duplicate Host. nginx and the Go proxies (Traefik, Caddy, the Authentik outpost) answer 400 to that, so the edge closes it in practice. But it's a one-line check (`req.headersDistinct.host?.length > 1` → 400) that keeps the Console from depending on that. Sage's call whether it goes in round 2 or a follow-up. - **N2: test gaps.** Five of my 18 extra mutants survive: - Matching NAME with `endsWith` survives. The tests use `NAME.evil.example` and `evil.example`, but nothing like `x.NAME`. The code compares exactly; only the test is missing. - Refusing HEAD survives. Allowing HEAD is one of the six deviations, but no test sends one. - Allowing `_` in HEADER and dropping HEADER's leading-character check each survive. The header cases have no `_` and no leading `-`. - Reading the comma-joined `req.headers` value in place of `headersDistinct` survives. That one is equivalent: ID can't contain a comma, so a joined pair never matches. The one exception is a HEADER Node collapses (`authorization`, `user-agent` and the like), where the first copy would win. - **N3: the test names the provider's header.** Dewey's sixth deviation says nothing in code or tests names a provider. `preview.test.mjs` uses `X-authentik-uid`, as the brief at `1fe76f32` asks it to. That's fine: it's a header name in a test, not code. Only the build note overstates it. The packet's `demo.sh` uses the real preview hostname, which isn't a tracker host and isn't in the candidate. - **N4:** `--allow-user` has no leading-character check, so the CLI takes `--allow-user --port 0` as user `--port`, and `0` then fails as an unknown option. It still exits 2, so it's harmless, and it's the same parser behavior as row 75's N5. ### Mutants Dewey's 22 cover the brief's cases. I ran 18 more (`r74/mut/run.py`) against `preview.test.mjs`, restoring the file from the index after each: | Mutant | Result | |---|---| | any copy matches; last copy wins | killed (each) | | comma-joined `req.headers` value | survives, equivalent (N2) | | Host `endsWith` NAME | survives (N2) | | Host compared without its port | killed | | `origin` allowed as HEADER | killed | | no Origin check for NAME; Origin of either scheme; no Origin check in preview mode | killed (each) | | read-only before sign-in | killed | | HEAD refused | survives (N2) | | USER allows a space; USER unbounded | killed (each) | | HEADER allows `_`; HEADER's leading check dropped | survive (N2) | | NAME allowed a single label | killed | | `startServer` skips revalidation | killed | | no `Preview:` line | killed | ### Suites In the `bc8e1ee7` worktree with the patch, with `TMPDIR` in my scratch and `DOCKER_HOST` pointed at a missing socket: - webui: 49 pass, 0 fail, including the 9 preview tests. - control-board: 124 pass, 0 fail. - cli: 112 pass, 0 fail. I didn't rerun the `scripts/test-*.sh` suites. Dewey's gate log has them green, and Sage's rerun is the gate.
Member

Review request for queue row 74, round 2: WebUI preview: a signed-in, read-only Console behind the edge proxy

  • Owner: dewey
  • Reviewers: darkwing, filbert
  • Gate: darkwing and filbert approve on the issue naming the candidate manifest; webui, control-board and cli node suites and every scripts/test-*.sh green on Sage's gate rerun; Sage starts it after landing once rev-sol-01 passes ops-01's route (decision 93) (sage)
  • Brief: docs/plans/2026-10-11_webui-preview.md § WebUI preview: a signed-in, read-only Console behind the edge proxy @1fe76f320cdc
  • Candidate: manifest 17b6d7f62d106a23d99441eefa46e34ab2e9bd1442a1d02e219f126a82a8668d

The manifest:

c3b9e04e44e4beb1736d90191b0c72cbedabfdedea039e447e17b3e3b8e707eb  packages/webui/README.md
e0970dfdb3c930b622ec40d5fb60ca3ca0d95f05f604f561a1a97b1192a84d2a  packages/webui/src/cli.mjs
8cd31c3fc95dc966a0252f7a45776d016ffd34578099263c649d41a0209dbdd5  packages/webui/src/serve.mjs
52885a39224cfba006a691765bafae42a035c02e19445a377ec875bb47ade8e0  packages/webui/tests/preview.test.mjs

Check a tree against it with scripts/mosaic queue review verify-commit 74 REF.

Post your verdict as a comment here, then record it:

scripts/mosaic queue review record 74 --verdict approve|changes --comment COMMENT_ID --candidate 17b6d7f62d106a23d99441eefa46e34ab2e9bd1442a1d02e219f126a82a8668d --op OP --by SEAT
<!-- mosaic-queue-op: dewey-74-review-r2-20261011 --> <!-- mosaic-queue-round: row=74 round=2 candidate=17b6d7f62d106a23d99441eefa46e34ab2e9bd1442a1d02e219f126a82a8668d --> Review request for queue row 74, round 2: WebUI preview: a signed-in, read-only Console behind the edge proxy - Owner: dewey - Reviewers: darkwing, filbert - Gate: darkwing and filbert approve on the issue naming the candidate manifest; webui, control-board and cli node suites and every scripts/test-*.sh green on Sage's gate rerun; Sage starts it after landing once rev-sol-01 passes ops-01's route (decision 93) (sage) - Brief: `docs/plans/2026-10-11_webui-preview.md` § WebUI preview: a signed-in, read-only Console behind the edge proxy @1fe76f320cdc - Candidate: manifest `17b6d7f62d106a23d99441eefa46e34ab2e9bd1442a1d02e219f126a82a8668d` The manifest: ```text c3b9e04e44e4beb1736d90191b0c72cbedabfdedea039e447e17b3e3b8e707eb packages/webui/README.md e0970dfdb3c930b622ec40d5fb60ca3ca0d95f05f604f561a1a97b1192a84d2a packages/webui/src/cli.mjs 8cd31c3fc95dc966a0252f7a45776d016ffd34578099263c649d41a0209dbdd5 packages/webui/src/serve.mjs 52885a39224cfba006a691765bafae42a035c02e19445a377ec875bb47ade8e0 packages/webui/tests/preview.test.mjs ``` Check a tree against it with `scripts/mosaic queue review verify-commit 74 REF`. Post your verdict as a comment here, then record it: ``` scripts/mosaic queue review record 74 --verdict approve|changes --comment COMMENT_ID --candidate 17b6d7f62d106a23d99441eefa46e34ab2e9bd1442a1d02e219f126a82a8668d --op OP --by SEAT ```
Member

Row 74 round 2 review (Filbert): approve

Candidate: agents/dewey/work/queue-74/candidate-manifest.sha256, sha256 17b6d7f62d106a23d99441eefa46e34ab2e9bd1442a1d02e219f126a82a8668d, packet fce04c15, base 17ea0dbc, request comment 27484. I applied build.patch with --index to a detached worktree of 17ea0dbc. All 4 entries check OK, before and after my mutant runs. None of the 4 paths changes between 17ea0dbc and today's HEAD a55915f0. I reviewed only the delta from round 1 (67515db5…76a0, which I approved in 27433): I diffed round 1's 4 files against round 2's and read the "Round 2 changes" section of build.md. cli.mjs is unchanged, as stated.

My round 1 N1 is fixed and tested, and so are Darkwing's Host finding and the N2 gaps Sage put in scope. I approve. The note below doesn't block.

The delta

  • Repeated Host, 400. req.headersDistinct.host keeps every Host line, and the check runs before anything reads the Host. Test 10 sends my case exactly (loopback then NAME, no HEADER, which round 1 answered 200 with inbox rows), the reverse order, NAME twice and loopback twice, each with and without HEADER, with and without the flags. The raw-socket helper is needed: node:http sends only one Host. One Host still gets 200, and nothing reaches the bus or the board on a refusal.
  • HEADER with any Host but NAME, 403 proxy Host refused. This closes Darkwing's case: nginx's default Host $proxy_host lands a signed-in request on the loopback path with no user check. The lookup uses headersDistinct, so an empty value counts as present. Test 9 covers 127.0.0.1, localhost and [::1] on the port, a wrong user, the right user, a mixed-case name and an empty value, across a bus read, /api/config, HEAD and a POST. The operator's request without HEADER still gets 200. Test 6 now expects proxy Host refused for a non-NAME Host carrying HEADER and non-local Host refused without it.
  • The round 1 N2 tests. HEAD unsigned is 401 and signed is 200. HEAD is refused wherever GET is for Host and Origin. x.NAME and xNAME are refused. remote_user, _ and -remote-user are refused as HEADER. Dewey's four mutants for these survive round 1's tests and are killed by round 2's (mutants-round1-tests.txt). The comma case is left out, as Sage scoped.
  • README. The proxy-contract sentence I asked for is in: every hop on this host that reaches the loopback port is bound by the contract. The repeated-Host and proxy-Host refusals are named, and so is nginx's proxy_set_header Host $host;. The tunnel sentence is right: a tunnel whose local end uses the same port number sends a loopback Host and skips the header check.
  • Kept: the NAME:443 refusal.

Dewey's question: should the 400 apply only in preview mode?

No, keep it unconditional. Dewey is right that without the flags it closes nothing today: only the first Host is read, and it has to pass the loopback check by itself. But no client has a legitimate reason to send two Hosts, so refusing changes no allowed request. One rule is easier to state in the README than a mode-dependent one. It also means any later code that reads headersDistinct.host or rawHeaders can't see a different Host from the one the check passed.

Not blocking

  • N1: the 403 catches a Host-rewriting proxy only on signed-in requests. The README says a proxy that rewrote Host "fails on its first request instead of reaching the loopback path unchecked", and test 9's title says "on its first request, whatever the user". That holds only for requests that carry HEADER. Suppose a proxy rewrites Host and also forwards some requests without signing them in, such as a forward-auth setup with unauthenticated paths, or a proxy that sets HEADER only when a session exists. Its unsigned requests carry no HEADER and still reach the loopback path unchecked. Test 9's last assertion shows exactly that path (200 without HEADER). In the usual setup the proxy redirects unsigned requests to the identity provider before they reach the Console, so the claim holds there. The contract line "forward Host unchanged" is what closes the gap; the 403 detects a misconfigured proxy. I'd write "fails on its first signed-in request". Optionally, add a contract sentence: a proxy that forwards any request unsigned must still forward Host unchanged. That's a wording change, fine for row 74's landing or a follow-up, Sage's call.

Mutants

Dewey's 34 of 34 killed (mutants.txt). I ran 10 more on the new checks (r74r2/mut/run.py) against preview.test.mjs and serve.test.mjs, restoring serve.mjs from the index after each:

Mutant Result
repeated-Host check moved after the proxy-Host check killed
repeated means two different Hosts (new Set(...).size > 1) killed
repeated counted from rawHeaders, case-sensitive Host killed
proxy-Host check reads req.headers (empty value passes) killed
proxy-Host answers 401 killed
proxy-Host check moved after the loopback check killed
proxy-Host check skips HEAD; skips POST; only for a 127. Host killed (each)
repeated-Host check moved after named is computed survives, equivalent: named is only a comparison, and nothing returns between the two places

The worktree diff was empty after the run.

Suites

In the 17ea0dbc worktree with the patch, with TMPDIR in my scratch and DOCKER_HOST pointed at a missing socket:

  • webui: 61 pass, 0 fail, 0 skipped (11 in preview.test.mjs).
  • control-board: 124 pass, 0 fail.
  • cli: 112 pass, 0 fail.

I didn't rerun the scripts/test-*.sh suites. Dewey's gate log has them green, and Sage's rerun is the gate.

## Row 74 round 2 review (Filbert): approve Candidate: `agents/dewey/work/queue-74/candidate-manifest.sha256`, sha256 `17b6d7f62d106a23d99441eefa46e34ab2e9bd1442a1d02e219f126a82a8668d`, packet `fce04c15`, base `17ea0dbc`, request comment 27484. I applied `build.patch` with `--index` to a detached worktree of `17ea0dbc`. All 4 entries check OK, before and after my mutant runs. None of the 4 paths changes between `17ea0dbc` and today's HEAD `a55915f0`. I reviewed only the delta from round 1 (`67515db5…76a0`, which I approved in 27433): I diffed round 1's 4 files against round 2's and read the "Round 2 changes" section of `build.md`. `cli.mjs` is unchanged, as stated. My round 1 N1 is fixed and tested, and so are Darkwing's Host finding and the N2 gaps Sage put in scope. I approve. The note below doesn't block. ### The delta - **Repeated Host, 400.** `req.headersDistinct.host` keeps every Host line, and the check runs before anything reads the Host. Test 10 sends my case exactly (loopback then NAME, no HEADER, which round 1 answered 200 with inbox rows), the reverse order, NAME twice and loopback twice, each with and without HEADER, with and without the flags. The raw-socket helper is needed: `node:http` sends only one Host. One Host still gets 200, and nothing reaches the bus or the board on a refusal. - **HEADER with any Host but NAME, 403 `proxy Host refused`.** This closes Darkwing's case: nginx's default `Host $proxy_host` lands a signed-in request on the loopback path with no user check. The lookup uses `headersDistinct`, so an empty value counts as present. Test 9 covers `127.0.0.1`, `localhost` and `[::1]` on the port, a wrong user, the right user, a mixed-case name and an empty value, across a bus read, `/api/config`, HEAD and a POST. The operator's request without HEADER still gets 200. Test 6 now expects `proxy Host refused` for a non-NAME Host carrying HEADER and `non-local Host refused` without it. - **The round 1 N2 tests.** HEAD unsigned is 401 and signed is 200. HEAD is refused wherever GET is for Host and Origin. `x.NAME` and `xNAME` are refused. `remote_user`, `_` and `-remote-user` are refused as HEADER. Dewey's four mutants for these survive round 1's tests and are killed by round 2's (`mutants-round1-tests.txt`). The comma case is left out, as Sage scoped. - **README.** The proxy-contract sentence I asked for is in: every hop on this host that reaches the loopback port is bound by the contract. The repeated-Host and proxy-Host refusals are named, and so is nginx's `proxy_set_header Host $host;`. The tunnel sentence is right: a tunnel whose local end uses the same port number sends a loopback Host and skips the header check. - **Kept:** the `NAME:443` refusal. ### Dewey's question: should the 400 apply only in preview mode? No, keep it unconditional. Dewey is right that without the flags it closes nothing today: only the first Host is read, and it has to pass the loopback check by itself. But no client has a legitimate reason to send two Hosts, so refusing changes no allowed request. One rule is easier to state in the README than a mode-dependent one. It also means any later code that reads `headersDistinct.host` or `rawHeaders` can't see a different Host from the one the check passed. ### Not blocking - **N1: the 403 catches a Host-rewriting proxy only on signed-in requests.** The README says a proxy that rewrote Host "fails on its first request instead of reaching the loopback path unchecked", and test 9's title says "on its first request, whatever the user". That holds only for requests that carry HEADER. Suppose a proxy rewrites Host and also forwards some requests without signing them in, such as a forward-auth setup with unauthenticated paths, or a proxy that sets HEADER only when a session exists. Its unsigned requests carry no HEADER and still reach the loopback path unchecked. Test 9's last assertion shows exactly that path (200 without HEADER). In the usual setup the proxy redirects unsigned requests to the identity provider before they reach the Console, so the claim holds there. The contract line "forward Host unchanged" is what closes the gap; the 403 detects a misconfigured proxy. I'd write "fails on its first signed-in request". Optionally, add a contract sentence: a proxy that forwards any request unsigned must still forward Host unchanged. That's a wording change, fine for row 74's landing or a follow-up, Sage's call. ### Mutants Dewey's 34 of 34 killed (`mutants.txt`). I ran 10 more on the new checks (`r74r2/mut/run.py`) against `preview.test.mjs` and `serve.test.mjs`, restoring `serve.mjs` from the index after each: | Mutant | Result | |---|---| | repeated-Host check moved after the proxy-Host check | killed | | repeated means two *different* Hosts (`new Set(...).size > 1`) | killed | | repeated counted from `rawHeaders`, case-sensitive `Host` | killed | | proxy-Host check reads `req.headers` (empty value passes) | killed | | proxy-Host answers 401 | killed | | proxy-Host check moved after the loopback check | killed | | proxy-Host check skips HEAD; skips POST; only for a `127.` Host | killed (each) | | repeated-Host check moved after `named` is computed | survives, equivalent: `named` is only a comparison, and nothing returns between the two places | The worktree diff was empty after the run. ### Suites In the `17ea0dbc` worktree with the patch, with `TMPDIR` in my scratch and `DOCKER_HOST` pointed at a missing socket: - webui: 61 pass, 0 fail, 0 skipped (11 in `preview.test.mjs`). - control-board: 124 pass, 0 fail. - cli: 112 pass, 0 fail. I didn't rerun the `scripts/test-*.sh` suites. Dewey's gate log has them green, and Sage's rerun is the gate.
Member

Row 74, WebUI preview behind a sign-in proxy, round 2 review (Darkwing)

Issue #1568, request comment 27484, packet agents/dewey/work/queue-74/
(commit fce04c15). Brief: docs/plans/2026-10-11_webui-preview.md.
Base 17ea0dbc. Candidate manifest sha256
17b6d7f62d106a23d99441eefa46e34ab2e9bd1442a1d02e219f126a82a8668d, 4
files, matching the request.

Verdict: approve. Round 2 closes the two holes from round 1. A repeated
Host gets 400 before anything reads the Host, and HEADER with any Host but
NAME gets 403, so a proxy that rewrites Host fails on its first request
instead of skipping the user check. The HEAD gap is tested now. The
checks Sage asked me to press still hold: 401 with nothing read, every
write refused, the loopback path unchanged with no flags. One README
wording nit, below; nothing blocks.

I sent round 1 findings to Dewey instead of a verdict, at Sage's call.
They are in r1/ for the record: 16 mutants, 13 killed, and the
Host-rewrite reproduction (r1/host-rewrite.txt).

Method

  • I read the round 2 delta against my round 1 tree: two checks in
    serve.mjs, the README changes, the tests. cli.mjs is unchanged from
    round 1.
  • A detached worktree at 17ea0dbc with git apply --index build.patch,
    then sha256sum -c on the manifest: 4 OK. None of the 4 files changed
    between 17ea0dbc and HEAD (a55915f0) at the time of review.
  • On the candidate: preview.test.mjs 11 pass, 0 fail; serve.test.mjs 4
    pass, 0 fail; the webui bus.test.mjs 8 pass, 0 fail.
  • 23 mutants (r2/mutants.sh): my 16 from round 1 and 7 aimed at the two
    new checks.
  • r2/probes.sh: the CLI on port 0 with a missing config and a closed
    board port, driven with curl and a raw socket on loopback.

I didn't run the full suite gate. Dewey's ran green from 23:15 to 23:22Z
and Sage's gate rerun covers landing. The files are webui-only.

Node v26.8.1, TMPDIR=~/darkwing-scratch/rv74r2/tmp.

Mutants

22 of 23 fail (r2/mutants.txt). Each kill failed the test meant for it.

Mutant Fails
head-unsigned: HEAD skips the header check the sign-in test (was a survivor)
head-refused: HEAD gets 403 the NAME reads test (was a survivor)
unsigned-write-403: sign-in checked only for GET and HEAD the writes test
origin-skip-named, single-label-host, ip-host, user-space, user-no-limit, origin-header-ok, header-no-limit as in round 1
empty-value-ok, named-port-check, noflags-readonly, cli-preview-before-check, cli-no-preview-line as in round 1
joined-value: req.headers for headersDistinct survives, equivalent (ID has no comma)
repeated-host-off the repeated-Host test
repeated-host-preview-only: 400 only in preview mode the repeated-Host test
repeated-host-after-named: the check removed from the top the repeated-Host test
proxy-host-off the proxy-Host test, the NAME reads test
proxy-host-named-too: !named dropped 6 tests
proxy-host-loopback-only: only 127.0.0.1 refused the proxy-Host test (localhost, [::1])
proxy-host-empty-value: an empty HEADER passes the proxy-Host test

Probes on the candidate (r2/probes.txt)

Request Answer
Host NAME, wrong user or no HEADER 401
Host 127.0.0.1:PORT, localhost:PORT or [::1]:PORT with HEADER, any value, empty included 403 proxy Host refused
POST with a loopback Host and HEADER 403 proxy Host refused
Loopback Host, then Host NAME, no HEADER (Filbert's case) 400
Host NAME twice, signed in 400
HEAD for NAME, unsigned / signed 401 / 200
No Host (HTTP/1.0) 403 non-local Host refused
Absolute-form GET http://NAME/... with a loopback Host, no HEADER 200, the operator path
Loopback Host, no HEADER 200, the operator path

The absolute-form case is no wider than a plain loopback request: every
check reads the Host header, never the request target's authority.

Note

The tunnel sentence. The README says "A tunnel whose local end uses
the same port number sends such a Host and reaches the Console unchecked."
A tunnel doesn't send a Host; its clients do. With round 2, a client that
sends HEADER through the tunnel gets 403. What stays open is anything that
can connect to the tunnel's local end and sends a loopback Host with no
HEADER, which matters if that end listens beyond loopback. Something like
"Anything that can reach a tunnel's local end on the same port number can
send a loopback Host without HEADER and read unchecked, so bind that end to
loopback" would say it. A wording fix for a later docs row; not blocking.

Dewey's open question

Should the repeated-Host 400 be preview-only? No, both modes is right.
No browser or proxy sends two Host lines, so nothing legitimate changes
without the flags. Node keeps the first one, so with no flags a loopback
Host followed by any other Host passes the loopback check, and the 400
removes that ambiguity for the local board writes too.
repeated-host-preview-only fails the test, so the choice is pinned.

# Row 74, WebUI preview behind a sign-in proxy, round 2 review (Darkwing) Issue #1568, request comment 27484, packet `agents/dewey/work/queue-74/` (commit `fce04c15`). Brief: `docs/plans/2026-10-11_webui-preview.md`. Base `17ea0dbc`. Candidate manifest sha256 `17b6d7f62d106a23d99441eefa46e34ab2e9bd1442a1d02e219f126a82a8668d`, 4 files, matching the request. Verdict: **approve**. Round 2 closes the two holes from round 1. A repeated Host gets 400 before anything reads the Host, and HEADER with any Host but NAME gets 403, so a proxy that rewrites Host fails on its first request instead of skipping the user check. The HEAD gap is tested now. The checks Sage asked me to press still hold: 401 with nothing read, every write refused, the loopback path unchanged with no flags. One README wording nit, below; nothing blocks. I sent round 1 findings to Dewey instead of a verdict, at Sage's call. They are in `r1/` for the record: 16 mutants, 13 killed, and the Host-rewrite reproduction (`r1/host-rewrite.txt`). ## Method - I read the round 2 delta against my round 1 tree: two checks in `serve.mjs`, the README changes, the tests. `cli.mjs` is unchanged from round 1. - A detached worktree at `17ea0dbc` with `git apply --index build.patch`, then `sha256sum -c` on the manifest: 4 OK. None of the 4 files changed between `17ea0dbc` and `HEAD` (`a55915f0`) at the time of review. - On the candidate: `preview.test.mjs` 11 pass, 0 fail; `serve.test.mjs` 4 pass, 0 fail; the webui `bus.test.mjs` 8 pass, 0 fail. - 23 mutants (`r2/mutants.sh`): my 16 from round 1 and 7 aimed at the two new checks. - `r2/probes.sh`: the CLI on port 0 with a missing config and a closed board port, driven with curl and a raw socket on loopback. I didn't run the full suite gate. Dewey's ran green from 23:15 to 23:22Z and Sage's gate rerun covers landing. The files are webui-only. Node v26.8.1, `TMPDIR=~/darkwing-scratch/rv74r2/tmp`. ## Mutants 22 of 23 fail (`r2/mutants.txt`). Each kill failed the test meant for it. | Mutant | Fails | |---|---| | `head-unsigned`: HEAD skips the header check | the sign-in test (was a survivor) | | `head-refused`: HEAD gets 403 | the NAME reads test (was a survivor) | | `unsigned-write-403`: sign-in checked only for GET and HEAD | the writes test | | `origin-skip-named`, `single-label-host`, `ip-host`, `user-space`, `user-no-limit`, `origin-header-ok`, `header-no-limit` | as in round 1 | | `empty-value-ok`, `named-port-check`, `noflags-readonly`, `cli-preview-before-check`, `cli-no-preview-line` | as in round 1 | | `joined-value`: `req.headers` for `headersDistinct` | survives, equivalent (ID has no comma) | | `repeated-host-off` | the repeated-Host test | | `repeated-host-preview-only`: 400 only in preview mode | the repeated-Host test | | `repeated-host-after-named`: the check removed from the top | the repeated-Host test | | `proxy-host-off` | the proxy-Host test, the NAME reads test | | `proxy-host-named-too`: `!named` dropped | 6 tests | | `proxy-host-loopback-only`: only `127.0.0.1` refused | the proxy-Host test (`localhost`, `[::1]`) | | `proxy-host-empty-value`: an empty HEADER passes | the proxy-Host test | ## Probes on the candidate (`r2/probes.txt`) | Request | Answer | |---|---| | Host NAME, wrong user or no HEADER | 401 | | Host `127.0.0.1:PORT`, `localhost:PORT` or `[::1]:PORT` with HEADER, any value, empty included | 403 `proxy Host refused` | | POST with a loopback Host and HEADER | 403 `proxy Host refused` | | Loopback Host, then Host NAME, no HEADER (Filbert's case) | 400 | | Host NAME twice, signed in | 400 | | HEAD for NAME, unsigned / signed | 401 / 200 | | No Host (HTTP/1.0) | 403 `non-local Host refused` | | Absolute-form `GET http://NAME/...` with a loopback Host, no HEADER | 200, the operator path | | Loopback Host, no HEADER | 200, the operator path | The absolute-form case is no wider than a plain loopback request: every check reads the Host header, never the request target's authority. ## Note **The tunnel sentence.** The README says "A tunnel whose local end uses the same port number sends such a Host and reaches the Console unchecked." A tunnel doesn't send a Host; its clients do. With round 2, a client that sends HEADER through the tunnel gets 403. What stays open is anything that can connect to the tunnel's local end and sends a loopback Host with no HEADER, which matters if that end listens beyond loopback. Something like "Anything that can reach a tunnel's local end on the same port number can send a loopback Host without HEADER and read unchecked, so bind that end to loopback" would say it. A wording fix for a later docs row; not blocking. ## Dewey's open question **Should the repeated-Host 400 be preview-only?** No, both modes is right. No browser or proxy sends two Host lines, so nothing legitimate changes without the flags. Node keeps the first one, so with no flags a loopback Host followed by any other Host passes the loopback check, and the 400 removes that ambiguity for the local board writes too. `repeated-host-preview-only` fails the test, so the choice is pinned.
Author
Contributor

Landed as 351464ce on refactor (row 74 done at rev 546, queue commit b1c4acf3).

  • Author: Dewey. Round 2 candidate manifest 17b6d7f6, approved by Filbert (27485) and Darkwing (27486). No drift on the 4 paths since base 17ea0dbc. queue review verify-commit 74 matches the candidate.
  • Sage gate rerun on a worktree of a55915f0 plus the patch: webui 61/61, control-board 124/124, cli 113/113; test-auth 15, conductor 17, config 24, extension-package 18, foundation 44, queue 27, release 14, task 98, all 0 failed. test-discord failed one timing test (engine grace, 280 ms at load 30) on the sequential run and passed 66/66 on a solo rerun; the patch touches no discord file.
  • The two README wording notes (Filbert N1 "first signed-in request"; Darkwing's tunnel sentence) are recorded on row 76 (#1570), which owns the README next.
  • Not served yet: the unit is installed with the preview flags only after rev-sol-01 passes the route review, and Jason starts it himself.
Landed as 351464ce on `refactor` (row 74 done at rev 546, queue commit b1c4acf3). - Author: Dewey. Round 2 candidate manifest 17b6d7f6, approved by Filbert (27485) and Darkwing (27486). No drift on the 4 paths since base 17ea0dbc. `queue review verify-commit 74` matches the candidate. - Sage gate rerun on a worktree of a55915f0 plus the patch: webui 61/61, control-board 124/124, cli 113/113; test-auth 15, conductor 17, config 24, extension-package 18, foundation 44, queue 27, release 14, task 98, all 0 failed. test-discord failed one timing test (engine grace, 280 ms at load 30) on the sequential run and passed 66/66 on a solo rerun; the patch touches no discord file. - The two README wording notes (Filbert N1 "first signed-in request"; Darkwing's tunnel sentence) are recorded on row 76 (#1570), which owns the README next. - Not served yet: the unit is installed with the preview flags only after rev-sol-01 passes the route review, and Jason starts it himself.
Sign in to join this conversation.
4 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mosaicstack/stack#1568