Files
stack/agents/darkwing/work/queue-74-review/review-r2.md
T
jason.woltjeandClaude Opus 5.5 3a2b051736 review(74): Darkwing round 2 packet, approve (#1568 comment 27486)
Round 2 candidate 17b6d7f6 on 17ea0dbc. 22 of 23 mutants killed, joined-value
equivalent; probes cover repeated Host, proxy Host, HEAD and absolute-form.
r1/ holds the round 1 findings sent to Dewey in place of a verdict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:30:48 -05:00

5.0 KiB

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.