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]>
This commit is contained in:
2026-10-11 18:30:48 -05:00
co-authored by Claude Opus 5.5
parent a55915f067
commit 3a2b051736
10 changed files with 410 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Row 74 review: what the Console does with a request that a proxy forwarded after rewriting Host
# to its upstream address (nginx's default, proxy_set_header Host $proxy_host). Loopback only, no
# config (MOSAIC_CONFIG names a missing file), board on a closed port.
export TMPDIR=~/darkwing-scratch/rv74/tmp
cd ~/darkwing-scratch/rv74/wt
NAME=preview.example.com HEADER=x-example-uid ID=jason LOG=$TMPDIR/host-rewrite-serve.log
MOSAIC_CONFIG=$TMPDIR/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 &
SRV=$!; trap 'kill $SRV' EXIT
until grep -q '^Mosaic Console' $LOG; do sleep 0.2; done
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 -o /dev/stdout -w '\n%{http_code}' "$@"); printf '%-58s %s %.80s\n' "$what" "${out##*$'\n'}" "$(printf '%s' "${out%$'\n'*}" | tr -d '\n')"; }
c "Host NAME, HEADER=mallory" -H "Host: $NAME" -H "$HEADER: mallory" $U/api/config
c "Host NAME, no HEADER" -H "Host: $NAME" $U/api/config
c "Host 127.0.0.1:PORT (rewritten), HEADER=mallory" -H "Host: 127.0.0.1:$PORT" -H "$HEADER: mallory" $U/api/config
c "Host 127.0.0.1:PORT (rewritten), no HEADER" -H "Host: 127.0.0.1:$PORT" $U/api/config
c "Host 127.0.0.1:PORT, X-Forwarded-For, HEADER=mallory" -H "Host: 127.0.0.1:$PORT" -H "X-Forwarded-For: 203.0.113.9" -H "$HEADER: mallory" $U/api/config
c "Host localhost:PORT, HEADER=mallory" -H "Host: localhost:$PORT" -H "$HEADER: mallory" $U/api/config
c "POST /api/seen, Host 127.0.0.1:PORT, HEADER=mallory" -H "Host: 127.0.0.1:$PORT" -H "$HEADER: mallory" -H 'Content-Type: application/json' -d '{}' $U/api/seen
echo "== HEAD (deviation 4), no test sends it"
c "HEAD /api/config, Host NAME, no HEADER" -I -H "Host: $NAME" $U/api/config
c "HEAD /api/config, Host NAME, HEADER=jason" -I -H "Host: $NAME" -H "$HEADER: $ID" $U/api/config
@@ -0,0 +1,10 @@
Host NAME, HEADER=mallory 401 {"error":"sign-in required"}
Host NAME, no HEADER 401 {"error":"sign-in required"}
Host 127.0.0.1:PORT (rewritten), HEADER=mallory 200 {"board":"http://127.0.0.1:9"}
Host 127.0.0.1:PORT (rewritten), no HEADER 200 {"board":"http://127.0.0.1:9"}
Host 127.0.0.1:PORT, X-Forwarded-For, HEADER=mallory 200 {"board":"http://127.0.0.1:9"}
Host localhost:PORT, HEADER=mallory 200 {"board":"http://127.0.0.1:9"}
POST /api/seen, Host 127.0.0.1:PORT, HEADER=mallory 403 {"error":"read-only","message":"the Console preview is read-only"}
== HEAD (deviation 4), no test sends it
HEAD /api/config, Host NAME, no HEADER 401 HTTP/1.1 401 Unauthorized␍x-content-type-options: nosniff␍referrer-policy: no-re
HEAD /api/config, Host NAME, HEADER=jason 200 HTTP/1.1 200 OK␍x-content-type-options: nosniff␍referrer-policy: no-referrer␍con
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# Darkwing's own row 74 mutants, aimed at sites Dewey's 22 don't cover. Each edits one source file
# in the review worktree, runs preview.test.mjs (or serve.test.mjs), then restores the file from
# the worktree index, which holds the candidate.
export TMPDIR=~/darkwing-scratch/rv74/tmp
cd ~/darkwing-scratch/rv74/wt
M=~/darkwing-scratch/rv59/mut.mjs
S=packages/webui/src/serve.mjs; C=packages/webui/src/cli.mjs
PT=packages/webui/tests/preview.test.mjs; ST=packages/webui/tests/serve.test.mjs
run() { # name file from to test
node $M "$2" "$3" "$4" || { echo "$1: MUTATION DID NOT APPLY"; return; }
env -u NODE_TEST_CONTEXT node --test --test-timeout=30000 "$5" > $TMPDIR/mut-$1.log 2>&1
echo "$1: rc=$? $(grep -E '^ℹ (pass|fail) ' $TMPDIR/mut-$1.log | tr '\n' ' ')"
grep -E '^✖ ' $TMPDIR/mut-$1.log | sed 's/ ([0-9.]*ms)$//; s/^/ /' | sort -u | head -4
git checkout -q -- "$2"
}
run head-unsigned $S ' if (named) {
// One value' ' if (named && req.method !== '"'HEAD'"') {
// One value' $PT
run unsigned-write-403 $S ' if (named) {
// One value' ' if (named && (req.method === '"'GET'"' || req.method === '"'HEAD'"')) {
// One value' $PT
run head-refused $S "req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403" "req.method !== 'GET') return json(res, 403" $PT
run origin-skip-named $S "if (req.headers.origin && req.headers.origin !== (named ?" "if (req.headers.origin && !named && req.headers.origin !== (named ?" $PT
run single-label-host $S '(?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;' '(?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;' $PT
run user-space $S 'const USER = /^[\x21-\x2b' 'const USER = /^[\x20-\x2b' $PT
run user-no-limit $S '\x2d-\x7e]{1,256}$/;' '\x2d-\x7e]{1,}$/;' $PT
run origin-header-ok $S "['host', 'origin'].includes(header)" "['host'].includes(header)" $PT
run header-no-limit $S 'const HEADER = /^[a-z0-9][a-z0-9-]{0,63}$/;' 'const HEADER = /^[a-z0-9][a-z0-9-]*$/;' $PT
run empty-value-ok $S 'if (!values || values.length !== 1 || values[0] !== preview.user)' 'if (values && (values.length !== 1 || values[0] !== preview.user))' $PT
run joined-value $S 'const values = req.headersDistinct[preview.header];' 'const values = req.headers[preview.header] === undefined ? undefined : [req.headers[preview.header]];' $PT
run named-port-check $S ' if (!named) {
const authority' ' if (true) {
const authority' $PT
run noflags-readonly $S "if (preview !== null && req.method !== 'GET'" "if (req.method !== 'GET'" $ST
run cli-preview-before-check $C ' options.preview = previewOptions(preview);
' '' $PT
run cli-no-preview-line $C "\${busLine}\${previewLine}" "\${busLine}" $PT
run ip-host $S "if (!HOSTNAME.test(host) || isIP(host))" "if (!HOSTNAME.test(host))" $PT
git diff --quiet -- $S $C && echo "restored: sources match the candidate index" || echo "NOT RESTORED"
@@ -0,0 +1,49 @@
head-unsigned: rc=0 ℹ pass 9 ℹ fail 0
unsigned-write-403: rc=1 ℹ pass 8 ℹ fail 1
✖ failing tests:
✖ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing
head-refused: rc=0 ℹ pass 9 ℹ fail 0
origin-skip-named: rc=1 ℹ pass 8 ℹ fail 1
✖ failing tests:
✖ the public name with the header reads the bus views, and the Host and Origin are checked first
single-label-host: rc=1 ℹ pass 8 ℹ fail 1
✖ failing tests:
✖ preview options are all three or none, and each is checked
user-space: rc=1 ℹ pass 8 ℹ fail 1
✖ failing tests:
✖ preview options are all three or none, and each is checked
user-no-limit: rc=1 ℹ pass 8 ℹ fail 1
✖ failing tests:
✖ preview options are all three or none, and each is checked
origin-header-ok: rc=1 ℹ pass 8 ℹ fail 1
✖ failing tests:
✖ preview options are all three or none, and each is checked
header-no-limit: rc=1 ℹ pass 8 ℹ fail 1
✖ failing tests:
✖ preview options are all three or none, and each is checked
empty-value-ok: rc=1 ℹ pass 6 ℹ fail 3
✖ a request for the public name needs the header equal to the user id, else 401 and nothing read
✖ failing tests:
✖ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing
✖ the CLI with all three flags serves the preview mode
joined-value: rc=0 ℹ pass 9 ℹ fail 0
named-port-check: rc=1 ℹ pass 3 ℹ fail 6
✖ a request for the public name needs the header equal to the user id, else 401 and nothing read
✖ failing tests:
✖ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing
✖ loopback requests are unchanged in preview mode, and the bus views work with the board down
noflags-readonly: rc=1 ℹ pass 2 ℹ fail 2
✖ failing tests:
✖ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
✖ real board fixture passes through WebUI; assets and isolated seen/reply work
cli-preview-before-check: rc=1 ℹ pass 7 ℹ fail 2
✖ failing tests:
✖ the CLI refuses a partial or invalid preview flag set at start
✖ the CLI with all three flags serves the preview mode
cli-no-preview-line: rc=1 ℹ pass 8 ℹ fail 1
✖ failing tests:
✖ the CLI with all three flags serves the preview mode
ip-host: rc=1 ℹ pass 8 ℹ fail 1
✖ failing tests:
✖ preview options are all three or none, and each is checked
restored: sources match the candidate index
@@ -0,0 +1,6 @@
// node mut.mjs <file> <from> <to>: replace exactly one occurrence, else exit 3.
import { readFileSync, writeFileSync } from 'node:fs';
const [f, a, b] = process.argv.slice(2);
const s = readFileSync(f, 'utf8'); const n = s.split(a).length - 1;
if (n !== 1) { console.error(`matches=${n}`); process.exit(3); }
writeFileSync(f, s.replace(a, () => b));
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# Darkwing's own row 74 mutants, aimed at sites Dewey's 22 don't cover. Each edits one source file
# in the review worktree, runs preview.test.mjs (or serve.test.mjs), then restores the file from
# the worktree index, which holds the candidate.
export TMPDIR=~/darkwing-scratch/rv74r2/tmp
cd ~/darkwing-scratch/rv74r2/wt
M=~/darkwing-scratch/rv59/mut.mjs
S=packages/webui/src/serve.mjs; C=packages/webui/src/cli.mjs
PT=packages/webui/tests/preview.test.mjs; ST=packages/webui/tests/serve.test.mjs
run() { # name file from to test
node $M "$2" "$3" "$4" || { echo "$1: MUTATION DID NOT APPLY"; return; }
env -u NODE_TEST_CONTEXT node --test --test-timeout=30000 "$5" > $TMPDIR/mut-$1.log 2>&1
echo "$1: rc=$? $(grep -E '^ℹ (pass|fail) ' $TMPDIR/mut-$1.log | tr '\n' ' ')"
grep -E '^✖ ' $TMPDIR/mut-$1.log | sed 's/ ([0-9.]*ms)$//; s/^/ /' | sort -u | head -4
git checkout -q -- "$2"
}
run head-unsigned $S ' if (named) {
// One value' ' if (named && req.method !== '"'HEAD'"') {
// One value' $PT
run unsigned-write-403 $S ' if (named) {
// One value' ' if (named && (req.method === '"'GET'"' || req.method === '"'HEAD'"')) {
// One value' $PT
run head-refused $S "req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403" "req.method !== 'GET') return json(res, 403" $PT
run origin-skip-named $S "if (req.headers.origin && req.headers.origin !== (named ?" "if (req.headers.origin && !named && req.headers.origin !== (named ?" $PT
run single-label-host $S '(?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;' '(?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;' $PT
run user-space $S 'const USER = /^[\x21-\x2b' 'const USER = /^[\x20-\x2b' $PT
run user-no-limit $S '\x2d-\x7e]{1,256}$/;' '\x2d-\x7e]{1,}$/;' $PT
run origin-header-ok $S "['host', 'origin'].includes(header)" "['host'].includes(header)" $PT
run header-no-limit $S 'const HEADER = /^[a-z0-9][a-z0-9-]{0,63}$/;' 'const HEADER = /^[a-z0-9][a-z0-9-]*$/;' $PT
run empty-value-ok $S 'if (!values || values.length !== 1 || values[0] !== preview.user)' 'if (values && (values.length !== 1 || values[0] !== preview.user))' $PT
run joined-value $S 'const values = req.headersDistinct[preview.header];' 'const values = req.headers[preview.header] === undefined ? undefined : [req.headers[preview.header]];' $PT
run named-port-check $S ' if (!named) {
const authority' ' if (true) {
const authority' $PT
run noflags-readonly $S "if (preview !== null && req.method !== 'GET'" "if (req.method !== 'GET'" $ST
run cli-preview-before-check $C ' options.preview = previewOptions(preview);
' '' $PT
run cli-no-preview-line $C "\${busLine}\${previewLine}" "\${busLine}" $PT
run ip-host $S "if (!HOSTNAME.test(host) || isIP(host))" "if (!HOSTNAME.test(host))" $PT
run repeated-host-off $S "if ((req.headersDistinct.host?.length ?? 0) > 1) return" "if (false) return" $PT
run repeated-host-preview-only $S "if ((req.headersDistinct.host?.length ?? 0) > 1) return" "if (preview !== null && (req.headersDistinct.host?.length ?? 0) > 1) return" $PT
run repeated-host-after-named $S " if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, 400, { error: 'repeated Host refused' });
" "" $PT
run proxy-host-off $S "if (preview !== null && !named && req.headersDistinct[preview.header]) return" "if (false) return" $PT
run proxy-host-named-too $S "if (preview !== null && !named && req.headersDistinct[preview.header]) return" "if (preview !== null && req.headersDistinct[preview.header]) return" $PT
run proxy-host-loopback-only $S "if (preview !== null && !named && req.headersDistinct[preview.header]) return" "if (preview !== null && !named && /^(127\\.0\\.0\\.1)(:|$)/.test(req.headers.host ?? '') && req.headersDistinct[preview.header]) return" $PT
run proxy-host-empty-value $S "&& !named && req.headersDistinct[preview.header]) return" "&& !named && req.headers[preview.header]) return" $PT
git diff --quiet -- $S $C && echo "restored: sources match the candidate index" || echo "NOT RESTORED"
@@ -0,0 +1,78 @@
head-unsigned: rc=1 ℹ pass 10 ℹ fail 1
✖ a request for the public name needs the header equal to the user id, else 401 and nothing read
✖ failing tests:
unsigned-write-403: rc=1 ℹ pass 10 ℹ fail 1
✖ failing tests:
✖ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing
head-refused: rc=1 ℹ pass 10 ℹ fail 1
✖ failing tests:
✖ the public name with the header reads the bus views, and the Host and Origin are checked first
origin-skip-named: rc=1 ℹ pass 10 ℹ fail 1
✖ failing tests:
✖ the public name with the header reads the bus views, and the Host and Origin are checked first
single-label-host: rc=1 ℹ pass 10 ℹ fail 1
✖ failing tests:
✖ preview options are all three or none, and each is checked
user-space: rc=1 ℹ pass 10 ℹ fail 1
✖ failing tests:
✖ preview options are all three or none, and each is checked
user-no-limit: rc=1 ℹ pass 10 ℹ fail 1
✖ failing tests:
✖ preview options are all three or none, and each is checked
origin-header-ok: rc=1 ℹ pass 10 ℹ fail 1
✖ failing tests:
✖ preview options are all three or none, and each is checked
header-no-limit: rc=1 ℹ pass 10 ℹ fail 1
✖ failing tests:
✖ preview options are all three or none, and each is checked
empty-value-ok: rc=1 ℹ pass 8 ℹ fail 3
✖ a request for the public name needs the header equal to the user id, else 401 and nothing read
✖ failing tests:
✖ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing
✖ the CLI with all three flags serves the preview mode
joined-value: rc=0 ℹ pass 11 ℹ fail 0
named-port-check: rc=1 ℹ pass 5 ℹ fail 6
✖ a request for the public name needs the header equal to the user id, else 401 and nothing read
✖ failing tests:
✖ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing
✖ loopback requests are unchanged in preview mode, and the bus views work with the board down
noflags-readonly: rc=1 ℹ pass 2 ℹ fail 2
✖ failing tests:
✖ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
✖ real board fixture passes through WebUI; assets and isolated seen/reply work
cli-preview-before-check: rc=1 ℹ pass 9 ℹ fail 2
✖ failing tests:
✖ the CLI refuses a partial or invalid preview flag set at start
✖ the CLI with all three flags serves the preview mode
cli-no-preview-line: rc=1 ℹ pass 10 ℹ fail 1
✖ failing tests:
✖ the CLI with all three flags serves the preview mode
ip-host: rc=1 ℹ pass 10 ℹ fail 1
✖ failing tests:
✖ preview options are all three or none, and each is checked
repeated-host-off: rc=1 ℹ pass 10 ℹ fail 1
✖ a repeated Host is 400 before any other check, in preview mode and without it
✖ failing tests:
repeated-host-preview-only: rc=1 ℹ pass 10 ℹ fail 1
✖ a repeated Host is 400 before any other check, in preview mode and without it
✖ failing tests:
repeated-host-after-named: rc=1 ℹ pass 10 ℹ fail 1
✖ a repeated Host is 400 before any other check, in preview mode and without it
✖ failing tests:
proxy-host-off: rc=1 ℹ pass 9 ℹ fail 2
✖ a proxy that rewrites Host to the loopback port gets 403 on its first request, whatever the user
✖ failing tests:
✖ the public name with the header reads the bus views, and the Host and Origin are checked first
proxy-host-named-too: rc=1 ℹ pass 5 ℹ fail 6
✖ a request for the public name needs the header equal to the user id, else 401 and nothing read
✖ failing tests:
✖ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing
✖ loopback requests are unchanged in preview mode, and the bus views work with the board down
proxy-host-loopback-only: rc=1 ℹ pass 9 ℹ fail 2
✖ a proxy that rewrites Host to the loopback port gets 403 on its first request, whatever the user
✖ failing tests:
✖ the public name with the header reads the bus views, and the Host and Origin are checked first
proxy-host-empty-value: rc=1 ℹ pass 10 ℹ fail 1
✖ a proxy that rewrites Host to the loopback port gets 403 on its first request, whatever the user
✖ failing tests:
restored: sources match the candidate index
@@ -0,0 +1,32 @@
#!/bin/bash
# Row 74 review: what the Console does with a request that a proxy forwarded after rewriting Host
# to its upstream address (nginx's default, proxy_set_header Host $proxy_host). Loopback only, no
# config (MOSAIC_CONFIG names a missing file), board on a closed port.
export TMPDIR=~/darkwing-scratch/rv74r2/tmp
cd ~/darkwing-scratch/rv74r2/wt
NAME=preview.example.com HEADER=x-example-uid ID=jason LOG=$TMPDIR/host-rewrite-serve.log
MOSAIC_CONFIG=$TMPDIR/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 &
SRV=$!; trap 'kill $SRV' EXIT
until grep -q '^Mosaic Console' $LOG; do sleep 0.2; done
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 -o /dev/stdout -w '\n%{http_code}' "$@"); printf '%-58s %s %.80s\n' "$what" "${out##*$'\n'}" "$(printf '%s' "${out%$'\n'*}" | tr -d '\n')"; }
c "Host NAME, HEADER=mallory" -H "Host: $NAME" -H "$HEADER: mallory" $U/api/config
c "Host NAME, no HEADER" -H "Host: $NAME" $U/api/config
c "Host 127.0.0.1:PORT (rewritten), HEADER=mallory" -H "Host: 127.0.0.1:$PORT" -H "$HEADER: mallory" $U/api/config
c "Host 127.0.0.1:PORT (rewritten), no HEADER" -H "Host: 127.0.0.1:$PORT" $U/api/config
c "Host 127.0.0.1:PORT, X-Forwarded-For, HEADER=mallory" -H "Host: 127.0.0.1:$PORT" -H "X-Forwarded-For: 203.0.113.9" -H "$HEADER: mallory" $U/api/config
c "Host localhost:PORT, HEADER=mallory" -H "Host: localhost:$PORT" -H "$HEADER: mallory" $U/api/config
c "POST /api/seen, Host 127.0.0.1:PORT, HEADER=mallory" -H "Host: 127.0.0.1:$PORT" -H "$HEADER: mallory" -H 'Content-Type: application/json' -d '{}' $U/api/seen
echo "== HEAD (deviation 4)"
c "HEAD /api/config, Host NAME, no HEADER" -I -H "Host: $NAME" $U/api/config
c "HEAD /api/config, Host NAME, HEADER=jason" -I -H "Host: $NAME" -H "$HEADER: $ID" $U/api/config
echo "== repeated Host, raw socket"
raw() { local what=$1; shift; printf '%-58s %s\n' "$what" "$(node -e '
const s=require("net").connect(+process.argv[1],"127.0.0.1",()=>s.end(process.argv[2].replace(/\\n/g,"\r\n")));let d="";s.on("data",c=>d+=c);s.on("end",()=>console.log(d.split("\r\n")[0]+" "+d.split("\r\n\r\n")[1]))' $PORT "$1" | cut -c1-80)"; }
raw "loopback Host then Host NAME, no HEADER" "GET /api/config HTTP/1.1\nHost: 127.0.0.1:$PORT\nHost: $NAME\nConnection: close\n\n"
raw "Host NAME then Host NAME, HEADER=jason" "GET /api/config HTTP/1.1\nHost: $NAME\nHost: $NAME\n$HEADER: $ID\nConnection: close\n\n"
raw "no Host (HTTP/1.0)" "GET /api/config HTTP/1.0\n\n"
raw "absolute-form NAME, loopback Host, no HEADER" "GET http://$NAME/api/config HTTP/1.1\nHost: 127.0.0.1:$PORT\nConnection: close\n\n"
raw "HEADER present but empty, loopback Host" "GET /api/config HTTP/1.1\nHost: 127.0.0.1:$PORT\n$HEADER:\nConnection: close\n\n"
raw "Host [::1]:PORT, HEADER=mallory" "GET /api/config HTTP/1.1\nHost: [::1]:$PORT\n$HEADER: mallory\nConnection: close\n\n"
@@ -0,0 +1,27 @@
Host NAME, HEADER=mallory 401 {"error":"sign-in required"}
Host NAME, no HEADER 401 {"error":"sign-in required"}
Host 127.0.0.1:PORT (rewritten), HEADER=mallory 403 {"error":"proxy Host refused","message":"the proxy must forward Host unchanged"}
Host 127.0.0.1:PORT (rewritten), no HEADER 200 {"board":"http://127.0.0.1:9"}
Host 127.0.0.1:PORT, X-Forwarded-For, HEADER=mallory 403 {"error":"proxy Host refused","message":"the proxy must forward Host unchanged"}
Host localhost:PORT, HEADER=mallory 403 {"error":"proxy Host refused","message":"the proxy must forward Host unchanged"}
POST /api/seen, Host 127.0.0.1:PORT, HEADER=mallory 403 {"error":"proxy Host refused","message":"the proxy must forward Host unchanged"}
== HEAD (deviation 4)
HEAD /api/config, Host NAME, no HEADER 401 HTTP/1.1 401 Unauthorized␍x-content-type-options: nosniff␍referrer-policy: no-re
HEAD /api/config, Host NAME, HEADER=jason 200 HTTP/1.1 200 OK␍x-content-type-options: nosniff␍referrer-policy: no-referrer␍con
== repeated Host, raw socket
loopback Host then Host NAME, no HEADER HTTP/1.1 400 Bad Request 21
{"error":"repeated Host refused"}
0
Host NAME then Host NAME, HEADER=jason HTTP/1.1 400 Bad Request 21
{"error":"repeated Host refused"}
0
no Host (HTTP/1.0) HTTP/1.1 403 Forbidden {"error":"non-local Host refused"}
absolute-form NAME, loopback Host, no HEADER HTTP/1.1 200 OK 1e
{"board":"http://127.0.0.1:9"}
0
HEADER present but empty, loopback Host HTTP/1.1 403 Forbidden 50
{"error":"proxy Host refused","message":"the proxy must forward Host unchanged"}
0
Host [::1]:PORT, HEADER=mallory HTTP/1.1 403 Forbidden 50
{"error":"proxy Host refused","message":"the proxy must forward Host unchanged"}
0
@@ -0,0 +1,97 @@
# 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.