Repository navigation
fix(serve,judge): exact-origin check, strict Host, PW_SERVE_ALLOWED_HOSTS; country inside spotlight - #43
Merged
Conversation
…OSTS; one-line country label Follow-up to #39's review (findings 1, 2, 3, 5, 7): - Origin must equal the desk's own origin (scheme, host and port, default ports filled in), so another app on localhost:3000 can no longer POST /research or /cancel. - Host is matched as a strict host[:port]; userinfo ("evil@localhost"), paths and other junk are refused. - PW_SERVE_ALLOWED_HOSTS (comma list) adds LAN/Tailscale names; the default stays loopback + PW_SERVE_HOST. README documents it in one line. - judge.compile_report: the node-supplied country label is stripped of invisibles, collapsed to one line and capped at 40 chars. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…E proxy note Adversarial review of the previous commit: a one-line label outside the delimiters still carried ~40 chars of node text. The block header is now "Contribution N" and "Country: ..." is the first line inside the data block. README: a TLS proxy in front of the desk must pass Host and X-Forwarded-Proto through, or the exact-origin check refuses POSTs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 11, 2026
wikithoughts
added a commit
that referenced
this pull request
Oct 11, 2026
…eport labels, cap kept joiners per run (#45) * fix(research,judge,sanitize): drop multi-line result urls, one-line report labels, cap kept joiners per run - research: a search result whose href holds whitespace or an invisible/control character is dropped (_result_url). urlparse() ignores an embedded newline, U+2028 or U+200B, so "https://good.example/a\n[S9] ..." passed the host checks and forged a source line in _clean() and search_structured(). - judge: node-supplied country/model in the final report (byline and per- contribution headings) are one plain line: invisibles stripped, markdown/HTML control chars dropped, "://" broken, length capped (_report_label). - sanitize: a run keeps at most 8 ZWJ/VS16/ZWNJ/RGI-flag forms; extra ones are stripped. Stripped invisibles don't break a run. - D55: correct the covert-channel figure (ZWJ and VS16 are independent per emoji gap = 2 bits; ZWNJ 1 bit per Arabic letter gap) and record the cap and what it does not bound. Follow-ups from the independent review of #42/#43. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(D55): record the unspaced-row cost of the run cap; per-flag and run-reset wording From the adversarial review of the previous commit: a row of more than 8 joined emoji with no space splits (pinned by a test), a black flag carries up to 2 bits, and any visible character outside the run classes starts a new run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
wikithoughts
added a commit
that referenced
this pull request
Oct 11, 2026
…EADME FORWARDED_ALLOW_IPS note (#46) - serve: an override entry that is not a plain host/IP (unbracketed IPv6 like fd7a::1, wildcards, 0.0.0.0, userinfo) was dropped silently, so a typo gave a bare 403 "forbidden host". Each such entry now prints one stderr warning (the repo's convention), checked once at startup in main() too. - README: a TLS proxy on another machine also needs FORWARDED_ALLOW_IPS, since uvicorn only trusts X-Forwarded-Proto from loopback by default; otherwise every POST is 403 "forbidden origin". Follow-up from the independent review of #43. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Follow-up to #39, from the independent review of #37/#39 (overnight run run-20261011-overnight, task W6-passiveworkers-3739fu).
passiveworkers/serve.py(middleware):Originheader is present, it must equal the desk's own origin: scheme, host and port, taken fromrequest.url.scheme+Host, with default ports 80/443 filled in. Previously only the hostname was compared, so a page onhttp://localhost:3000(any other local dev server) couldPOST /researchand/cancel/{id}.Hostmust be exactlyhost[:port](a name, or a bracketed IPv6 address). Userinfo (evil@localhost), paths, spaces,localhost:andlocalhost:80:80are refused. An absentHostwas already 403 and is now pinned by a test.PW_SERVE_ALLOWED_HOSTS: a comma list of extra hostnames or IPs for LAN or Tailscale use. Entries are lowercased, and junk or wildcard entries (*,0.0.0.0,bad@x) are ignored. The default is unchanged: loopback names plusPW_SERVE_HOST.README.md: one line documenting the override, with the note that a TLS proxy must passHostandX-Forwarded-Protothrough.passiveworkers/judge.py(compile_reportcountry label only). The country comes from a node's payload, so it is untrusted. It now sits inside the spotlight, as a first lineCountry: …that is stripped of invisibles, collapsed to one line and capped at 40 characters. The block header is now--- Contribution N ---. The first commit only one-lined the label; the adversarial pass showed that still let about 40 characters of node text sit outside the delimiters, so commit 2 moves it inside.tests/test_serve.py::80and a Docker-p 9000:8770remap;tests/test_judge_spotlight.py: the country label is inside the spotlight.Origin: http://localhost:8770againstHost: 127.0.0.1:8770is now 403, because it is a different origin; no real browser on the desk sends that pair.Findings addressed (claude-review-pw-37-39.md, PR #39 section)
Verification
ruff check . && pytest tests/ -q --cov=passiveworkers --cov-fail-under=75→ exit 0:All checks passed!,704 passed, 9 skipped, total coverage 80.36%. This was the declared gate, run at head c837677 in a.[dev]venv.python -m py_compile $(git ls-files '*.py')→ exit 0.bash scripts/check_app_js.sh && node scripts/fe_test.js→ exit 0,ALL FRONT-END FLOW CHECKS PASSED.The new tests fail on the old code. Restoring
serve.pyandjudge.pyto origin/main makes 17 tests fail:The absent-Host and same-origin tests pass on both versions; they pin existing behaviour.
Live check over a real socket. I ran
python -m passiveworkers.serveon 127.0.0.1:18771, withPW_HOMEandPW_REPORTS_DIRset to a scratch directory andPW_SERVE_ALLOWED_HOSTS=desk.tailnet.example, then sent requests with curl:/reportson 127.0.0.1 or localhostHost: evil.exampleHost: evil@localhost:18771Host: desk.tailnet.example:18771POST /researchwithOrigin: http://localhost:3000POST /cancel/nopewith the same-origin OriginPOST /cancel/nopewithOrigin: http://localhost:3000Origin: nullBrowser. Headless Chrome via chrome-devtools, at
http://127.0.0.1:18771/:fetch('/cancel/nope', {method:'POST'})from the page, which sends an Origin header, returned 404 from the route, not 403. The page's own POSTs still pass the new check./nopeprobes.This check ran on commit 1. Commit 2 does not touch
serve.py.An adversarial review pass ran on commit 1 (read-only subagent). It found no Host or Origin bypass across these probes:
null, uppercase scheme, a trailing slash, userinfo, a trailing space.Its label finding is fixed in commit 2.
Not verified / follow-ups
judge.compile_reportstill writes the raw nodecountry/modelinto the final report byline and the### {country} — {model}headings (judge.py, about lines 418-436). A node could forge a markdown heading in the report. This is not a prompt-injection path, but it needs a one-line sanitize in a separate PR.PW_SERVE_ALLOWED_HOSTSaccepts harmless junk names (.,-) that match the name regex. No browser sends such a Host.X-Forwarded-Proto) are documented but not tested.🤖 Generated with Claude Code