diff --git a/agents/darkwing/work/queue-74-review/r1/host-rewrite.sh b/agents/darkwing/work/queue-74-review/r1/host-rewrite.sh new file mode 100755 index 000000000..11ef5f474 --- /dev/null +++ b/agents/darkwing/work/queue-74-review/r1/host-rewrite.sh @@ -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 diff --git a/agents/darkwing/work/queue-74-review/r1/host-rewrite.txt b/agents/darkwing/work/queue-74-review/r1/host-rewrite.txt new file mode 100644 index 000000000..7da582b37 --- /dev/null +++ b/agents/darkwing/work/queue-74-review/r1/host-rewrite.txt @@ -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 diff --git a/agents/darkwing/work/queue-74-review/r1/mutants.sh b/agents/darkwing/work/queue-74-review/r1/mutants.sh new file mode 100755 index 000000000..f7659b700 --- /dev/null +++ b/agents/darkwing/work/queue-74-review/r1/mutants.sh @@ -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" diff --git a/agents/darkwing/work/queue-74-review/r1/mutants.txt b/agents/darkwing/work/queue-74-review/r1/mutants.txt new file mode 100644 index 000000000..9869c73b2 --- /dev/null +++ b/agents/darkwing/work/queue-74-review/r1/mutants.txt @@ -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 diff --git a/agents/darkwing/work/queue-74-review/r2/mut.mjs b/agents/darkwing/work/queue-74-review/r2/mut.mjs new file mode 100644 index 000000000..072bba00d --- /dev/null +++ b/agents/darkwing/work/queue-74-review/r2/mut.mjs @@ -0,0 +1,6 @@ +// node mut.mjs : 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)); diff --git a/agents/darkwing/work/queue-74-review/r2/mutants.sh b/agents/darkwing/work/queue-74-review/r2/mutants.sh new file mode 100755 index 000000000..64de1d29b --- /dev/null +++ b/agents/darkwing/work/queue-74-review/r2/mutants.sh @@ -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" diff --git a/agents/darkwing/work/queue-74-review/r2/mutants.txt b/agents/darkwing/work/queue-74-review/r2/mutants.txt new file mode 100644 index 000000000..987c4175f --- /dev/null +++ b/agents/darkwing/work/queue-74-review/r2/mutants.txt @@ -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 diff --git a/agents/darkwing/work/queue-74-review/r2/probes.sh b/agents/darkwing/work/queue-74-review/r2/probes.sh new file mode 100644 index 000000000..59821c161 --- /dev/null +++ b/agents/darkwing/work/queue-74-review/r2/probes.sh @@ -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" diff --git a/agents/darkwing/work/queue-74-review/r2/probes.txt b/agents/darkwing/work/queue-74-review/r2/probes.txt new file mode 100644 index 000000000..85d903242 --- /dev/null +++ b/agents/darkwing/work/queue-74-review/r2/probes.txt @@ -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 diff --git a/agents/darkwing/work/queue-74-review/review-r2.md b/agents/darkwing/work/queue-74-review/review-r2.md new file mode 100644 index 000000000..bf7fc31ba --- /dev/null +++ b/agents/darkwing/work/queue-74-review/review-r2.md @@ -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.