fix(web): biorouter web serves no transcripts, gates the turn that reaches a chat, and stops reflecting the URL into script context (#56) - #267
Merged
Conversation
…t reaches a chat (#56) `biorouter web`, the deprecated page `serve` superseded, answered `GET /api/sessions` with every user and scheduled chat on the machine and `GET /api/sessions/{id}` with any chat's full transcript, private ones included, behind no reach check. Without `--auth-token` (all a loopback bind requires) the auth middleware lets every request through; with one, the token sits in the process's argv, readable by any process of the same user. Both routes are removed rather than gated. The page never read the list, and read the transcript only for a message count and a tab title. The page's WebSocket was the larger way in. A message naming a private chat started elsewhere ran a turn there (Gate B rebinds the shared agent to the private model that chat's row names) and streamed the reply to whoever held the socket: the daemon's `POST /reply` under another name. `turn_reach` now judges it before anything touches the chat. The page is a public caller, except in a chat this server started, where it holds the tier of the provider the server was started on, so a private-model operator's own chat survives the first reply ratcheting it private. A private chat and an id that names nothing get one identical refusal. With privacy tiers off the gate is inert. Recorded as SD-13 in docs/deployment/serve-decisions.md. The wiring census gains the gate's may_read and may_write call sites.
…56) `GET /session/{name}` wrote the chat's name straight into an inline `<script>`: <script>window.BIOROUTER_SESSION_NAME = '{session_name}'; …</script> `session_name` is a path segment, so it is whatever the sender typed, and on the loopback bind that needs no `--auth-token` there is no credential in front of it. A `'` ended the string literal and a `</script>` ended the element. Measured, verbatim, from the pre-fix handler: <script>window.BIOROUTER_SESSION_NAME = '</script><img src=x onerror=alert(1)>"'&'; … On this page that is not defacement. The injected script runs on the server's own origin, reads the WebSocket token out of the same document, opens `/ws` with it, and sends a message to an agent that holds `developer__shell`. WebSockets are not subject to the same-origin policy, so that token is the only thing between a drive-by page and the socket, and the injection is handed it. The two boot values now leave script context entirely: they are written as attributes on a `<div id="biorouter-boot">` and read back through `dataset`. HTML-escaping a `<script>` body would not have been a fix — the HTML parser does not decode entities there, so `</script>` reaches the JavaScript parser verbatim — and a JSON `<script>` block would not either, because `serde_json` leaves `<` and `/` alone. In a double-quoted attribute the parser does decode, so the page gets the value exactly as it arrived and no byte can leave the attribute. The response carries a `Content-Security-Policy` whose `script-src` is `'self'`, which is why `index.html`'s suggestion pills now bind their handlers in `script.js` instead of carrying `onclick`. Four more holes on the same page put model-controlled text into `innerHTML` unescaped: a tool's name twice, and a tool call's arguments through `JSON.stringify` twice. A prompt injection in a file the agent reads reaches all four. Also finishes the reach gate d8cd7be added. Every socket message that names a chat is now judged before the chat is touched: `cancel` was not, and while it could only ever abort a turn the gate had already admitted, replying to one id and staying silent on another is a bit about a chat the sender may not reach. Recorded under SD-13 in docs/deployment/serve-decisions.md, with the displaced alternatives, the CORS allowance that is left open as a maintainer's call, and the standing recommendation to delete this deprecated command outright.
…optional (#56) Four holes on the same surface, each of which left an equivalent route to the capability the reflected XSS fix was meant to close. **No other origin may read the page the token is in.** `build_cors_layer` allow-listed `http://localhost:3000`, `http://127.0.0.1:3000` and this server's own origin whenever no `--auth-token` was passed. Without a token the auth middleware lets every request through, so a cross-origin `fetch` of `/session/…` that the browser permits reads the page — and `data-ws-token` is in it. Escaping the reflection and leaving this would have closed the sink and left the outcome. `--port` defaults to 3000, so on a default run all three entries are this server and the grant means nothing; on any other port it hands `http://…:3000` read access to a chat page on, say, `:8080`, and `--port 8080` is a documented invocation. There is deliberately no opt-in flag: the two routes a cross-origin browser client could have wanted are the ones the previous commit deleted, nothing in the repository reads any of the rest from another origin (`scripts/test_web.sh` uses `curl`), so an opt-in would be an opt-in to the token leak and nothing else. **`websocket_handler` had no `Origin` check on any path**, while `routes/workspace.rs` and `routes/apps.rs` both have one. CORS does not govern a handshake, so a page on any origin holding the token could drive an agent with `developer__shell`. Measured before the fix: a handshake carrying `Origin: http://localhost:3000` opened the socket. `origin_is_this_server` mirrors the daemon's `origin_matches_host` rather than calling it, because `biorouter-cli` does not depend on `biorouter-server` and must not start — SD-7 is why `serve` spawns `biorouterd` instead of linking it — exactly as `token_matches` already mirrors its `secret_matches` in this file. It is that rule's strict core with neither exception: no `is_local_origin` (PR #233 is removing that from the daemon's own socket gates, and here it would re-open the allowance above) and no `file://`, which exists for a renderer this server has no equivalent of. **The socket token was checked only when `--auth-token` was absent**, and `handle_web` made `ws_token` the empty string in exactly the other mode — so the skip was load-bearing. `token_matches("", "")` is `true`, which means deleting the `if` without changing the generation would have admitted every socket while reading like a tightening. Generation and check are both unconditional now, and an empty expected token is refused outright. **`--auth-token ""` is not a token.** `Some("")` satisfied the network-exposure guard, so `--host 0.0.0.0 --auth-token ""` bound to every interface behind a credential that `Authorization: Bearer ` satisfies. Refused at argument-parse time, and `validate_network_auth` treats an empty value as absent as well, since `handle_web` is public. SD-13 records all four, with the displaced alternatives and the note that PR #264 must merge first: the reach gate keys on a session id, and #264 is what stops ids being reissued after a delete.
Both sides appended a decision record to serve-decisions.md; keep both, in number order (SD-11 from #260, then this branch's SD-13).
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.
⚠ Security-sensitive — wants human review
Per
HOWTOAI.md("Always get human review for: security sensitive code"), thistouches an authentication-adjacent surface and closes a remote-code-execution
path. Please do not merge on CI alone.
Two things land here, on one surface: the reach gate
d8cd7be7added tobiorouter webis verified and finished, and the reflected XSS on the samepage is closed.
Part 1 — the reach gate, audited and finished
d8cd7be7removedGET /api/sessionsandGET /api/sessions/{id}(which servedevery chat on the machine, and any chat's full transcript, behind no reach check
and — without
--auth-token— behind no credential) and addedturn_reachso aWebSocket message naming a private chat cannot run a turn there.
I audited every path in
web.rsthat can reach a chat. There are five routesand two socket message types:
GET /GET /session/{name}GET /ws→messageturn_reachGET /ws→cancelGET /api/healthGET /static/{*path}Every store access in the file (
grepforsession_manager.,get_session,agent.reply) is either a creation, the gate's own metadata-only read, ordownstream of the gate.
The one gap:
cancel.handle_cancel_messagenamed a chat and was notjudged. It could only ever abort a turn the gate had already admitted — a handle
lands in
cancellationsonly afterhandle_user_messagepassed — so nothingprivate could be disturbed. What it could do is answer: it replied
Cancelledwhen a handle existed and said nothing when it did not, which is onebit about a chat the sender may not reach. It is gated now, which also makes the
property flat rather than derived: every socket message that names a chat is
judged before the chat is touched.
Residual I did not close, deliberately.
turn_reachruns before thetokio::spawn, so there is a narrow window in which another process could ratchetthe chat private between the gate and
agent.reply. That is the same shape thedaemon's own
session_reachhas, and Gate B at the top ofAgent::replystillgoverns the model binding. Closing it would mean re-judging inside the spawned
task; worth a maintainer's opinion rather than a unilateral restructure.
Fail-before evidence
d8cd7be7's parent6455bc21has both routes (web.rs:245-246) and theunescaped sink (
web.rs:354), so the new tests cannot compile against it — thefunctions they call do not exist there. To get real fail-before signal I removed
both
turn_reachcall sites from the current tree and re-ran:Then restored, and all 16 pass. The two concerns are independently pinned.
The refusal is not an existence oracle
Asserted as byte equality, at every capability, rather than as each answer
being vague — a vagueness check passes an implementation that adds one helpful
clause to the branch it can tell apart:
Plus end-to-end over the real socket:
server.send(&private)andserver.send("19700101_0")both return the identicalCHAT_OUT_OF_REACHframe,and now
server.cancel(...)does too. This was already correct ind8cd7be7;I verified it rather than changed it.
SD-13 is the right number
docs/deployment/serve-decisions.mdcarries SD-1…SD-9. SD-10 is claimed by#237, SD-11 by #240, SD-12 by #229 — all unmerged, so those numbers stay free.
SD-13 is this branch's. Nothing else was renumbered. The XSS is recorded as a
subsection inside SD-13 rather than as SD-14, so this PR claims exactly one
number and cannot collide with another in-flight record.
Part 2 — the reflected XSS
The sink
serve_sessionbuilt the page with:session_nameis the{session_name}path segment — whatever the sender typed —and a loopback bind requires no
--auth-token, so nothing stands in front of it.A
'ends the string literal; a</script>ends the element. Captured verbatimfrom the pre-fix handler by the new test:
Why this is RCE, not defacement
The injected script runs on the server's own origin. It reads the WebSocket token
out of the very document it was injected into, opens
/ws?token=…, and sends amessage to an agent holding
developer__shell. WebSockets are not subject tothe same-origin policy, so that token is the only thing standing between a
drive-by page and the socket — and the injection is handed it. One link the
operator clicks is arbitrary command execution as the operator.
The fix, and the two alternatives rejected
The values leave script context entirely. They are written as attributes on a
<div id="biorouter-boot" hidden data-session-name="…" data-ws-token="…">andread back through
dataset.<script>. Not a fix, and it is the trap: the HTMLparser does not decode entities inside
<script>, so</script>reachesthe JavaScript parser verbatim and nothing has been neutralised.
<script type="application/json">block.serde_jsonescapes forJSON, which says nothing about HTML — it leaves
<and/alone, so a valueholding
</scriptstill ends the element. It needs a second HTML-specificescape on top, which is the attribute answer with an extra step.
guess about a format that has changed before, it would refuse ids this route
serves today, and a correct escape does not need it. Fine to add later as depth.
so JS gets the value exactly as it arrived, and no byte can leave the attribute.
escape_html_attributecovers& < > " 'as a character walk rather than a chainof
replaces — a chain has an ordering hazard (escape&anywhere but first andit re-escapes the
&of every entity the earlier steps wrote) and a walk cannot.state.ws_tokengoes through the same escape even though the server generates it:a value is escaped for where it is going, never for where the reader believes it
came from.
Defence in depth, not the fix: the response now carries
Content-Security-Policy: … script-src 'self' …, so a future missed sink onthis page is inert. That is why
index.html's five suggestion pills lost theironclickattributes and bind throughdata-suggestioninscript.js— an inlinehandler is exactly what
script-src 'self'refuses, so the two move together.Every other reflected value I audited
session_name→ inline<script>serve_sessionstate.ws_token→ inline<script>serve_sessiondata.tool_name→innerHTMLscript.jshandleToolRequestdata.tool_name→innerHTMLscript.jshandleToolConfirmationJSON.stringify(data.arguments)→innerHTMLhandleToolRequestJSON.stringify(data.arguments)→innerHTMLhandleToolConfirmationdata.arguments.command(asaction) →innerHTMLhandleToolRequestpathwas already escaped; this one was noturi.query()→Location:headerserve_indexuri.query()is the raw (still percent-encoded) query, hyper rejects raw CR/LF in a request target, and the target is always the relative/session/{server-generated-id}?…. No header injection, no open redirectformatMessageContent& < >before any markup is added, and the one attribute it writes islanguage-${lang}withlangconstrained to(\w+)??q=URL parametergetQueryParamtextarea.value, notinnerHTML?session=/?name=URL parametersgetSessionIdsession_id, which Part 1's gate judgesscript.jsescapeHtml'd, all in element contentescapeHtmlinscript.jsdoes not escape", which is adequate for all of theabove and only because every one of those holes is in element content, never in
an attribute value. Noted in a comment beside them so the next person adding one
does not assume otherwise.
Flagged, not fixed→ CLOSED in the second review roundbuild_cors_layerallow-listedhttp://localhost:3000/127.0.0.1:3000, so a page there couldread
/session/…cross-origin and lift the WebSocket token — the same capability the XSS gave.This was originally left open here as a maintainer's call, and that was the wrong call. It is
closed in
a6f0d246; see "Second review round" below, finding 4. Three further holes on the samesurface were found and closed in that round, including a missing
Origincheck on the WebSocketitself. Read that section — it supersedes this one.
Recommendation: delete
biorouter webbiorouter webis deprecated in favour ofbiorouter serve, which serves the realinterface. Every hole above lives in a page nothing else uses. Deleting the
command would close all of them permanently and retire SD-13's whole surface with
it. I fixed the holes rather than deleting the command, because that is not a call
to make unilaterally — but it is the answer I would pick.
Tests
crates/biorouter-cli/src/commands/web.rs, 16 tests in the module (7 new):an_attribute_escape_neutralises_every_character_that_could_leave_one— theescaper, including that
<comes back as&lt;and not doubled.a_session_name_cannot_reach_script_context— drives the real router over areal socket with
</script><img src=x onerror=alert(1)>"'&. Asserted as "thepage has exactly the script elements its own template has" rather than as "the
payload does not appear", because the weaker form passes an implementation that
HTML-escapes inside the
<script>body — which is not a fix.an_ordinary_session_name_still_reaches_the_page— a breakout test alone passesa handler that drops the value entirely.
the_page_is_served_under_a_policy_that_refuses_inline_script— the header, andthat the template carries no
onclickfor it to refuse.the_page_reads_its_boot_values_from_attributes— nowindow.BIOROUTER_*left,so the server cannot be pushed back into script context to satisfy the page.
model_controlled_values_are_escaped_before_they_become_markup— the fourscript.jsholes stay closed.a_cancel_naming_an_unreachable_chat_is_refused_like_a_message— the gate, plusthat a reachable cancel with nothing running answers nothing, so the refusal is
the gate and not merely "no such turn".
Verification run
Notes on the last two:
origin/maindoes notcarry fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0) #237 yet, so
privacy_guard_wiring.rsmerged with no textual orsemantic conflict. I ran it rather than assumed it, and separately confirmed the
may_read/may_writecounts inweb.rsare stillc(1, 0, 1)each — this PRadds a
turn_reachcall site, which the census does not count.clippy::too_many_linesonsend_prompt_turn(
crates/biorouter/src/agents/workspace_extension.rs:3406, 101/100 lines),already red on a clean
mainand being fixed elsewhere. No new findings.serve_lifecycle's three tests fail on a barecargo test -p biorouter-clibecause
target/debug/biorouterddoes not exist — the precondition the testitself prints, and documented in
CLAUDE.md. Aftercargo build -p biorouter-server --bin biorouterdall three pass.🤖 Generated with Claude Code
Second review round — four more holes on the same surface, all closed
A second reviewer went over this PR independently. Everything they found was real, with two
corrections to how it was stated. All four are fixed in
a6f0d246. The through-line: this PR'sheadline was that the reflected XSS could no longer hand out the WebSocket token and drive an
agent — and there were three other routes to that same capability.
1 — HIGH:
websocket_handlerhad noOrigincheck on any pathConfirmed, and it was byte-identical to
main./wsis the chat: a message on it runs a turn andstreams the reply. CORS does not govern a WebSocket handshake, so a page on any origin holding
the token could drive an agent carrying
developer__shell(CSWSH). The tree's other two upgradesites —
routes/workspace.rs:63androutes/apps.rs:560— have had such a check all along.Measured before the fix, from the new test, with a real handshake:
On reusing
origin_matches_hostrather than writing a third ruleI could not call it, and the reason is a crate boundary, not a preference:
origin_matches_hostlives incrates/biorouter-server/src/routes/mod.rs, andbiorouter-clidoes not depend on
biorouter-server(crates/biorouter-cli/Cargo.toml— no such entry).That is SD-7:
servespawnsbiorouterdas a subprocess precisely so the command-lineinterface does not link the server. Adding that dependency to share a six-line comparison would
undo it.
So
origin_is_this_servermirrors the rule, exactly astoken_matchesin this same filealready mirrors the daemon's
secret_matches("Mirrors the daemon'ssecret_matches(biorouter-server auth.rs)",
web.rs:151) for the identical reason. If the two ever need to be onesymbol, the move is into the
bioroutercore library both already depend on — noted in the doccomment and in SD-13.
I read #233 (
fix/qa-d-f7-ws-same-origin) and took its shape, not the older one. Specificallyits ruling that
is_local_originis out of socket gates — "is_local_originis the CORS rulenow and nothing else; do not hand it back to a socket". So mine is that rule's strict core with
neither of its exceptions, and each absence is deliberate:
is_local_origin. Beyond QA-D F7: the WebSocket origin gates are a real same-origin test #233's ruling, here it would re-open finding 4 below byadmitting a page on
localhost:3000.file://, no declared-renderer origin. Those exist for the Electron renderer, whichreaches the daemon from another local origin. This server serves its own page from its own origin
and has no such client, so an opaque origin is refused like any other.
A client sending no
Originis still let past, as the daemon's gates let one past: non-browserclient, token still guards it.
Correction to how the finding was stated
The composed attack as described — "a foreign page reaches chats this server started", via
started_hereplus the guessable<day>_<N>id — does not complete, once finding 4 lands. Aforeign page can still cause
GET /to create sessions (CORS never stopped requests being sent,only responses being read) and can guess their ids, but it cannot read the redirect's
Locationcross-origin, and after finding 4 it cannot read the page carrying the token either. The socket
gate is therefore the second independent lock, not the only one — which is exactly why both
belong here: they fail independently. I am implementing it because the divergence from the other
two upgrade sites is real and the layering is worth having, not because the path was open.
2 — The token check was skipped when
--auth-tokenwas setConfirmed, and "just make it unconditional" is a trap that I have to flag explicitly, because
taking the instruction literally would have made things worse while looking like a fix.
handle_webmadews_token = String::new()in exactly the mode where the check was skipped —so the skip was load-bearing, not an oversight. And
token_matches("", "")istrue. Deleting theifwithout touching the generation would have madequery.token.as_deref().unwrap_or("")matchthe empty expected token, admitting every socket, unauthenticated, while reading as a
tightening in review.
What the branch was for, established before removing it: in
--auth-tokenmode the handshakeis authenticated by
auth_middleware(the layer applies to/ws), and the page could not put atoken in the query because there was none to put. That reasoning was sound; the empty-string
generation it relied on was the landmine.
The fix, therefore, is three things and not one: generation is unconditional (a v4 UUID in both
modes, so the page always has one to send and both modes gain a second lock on top of the
middleware), the check is unconditional, and an empty expected token is refused outright so the
landmine cannot be re-armed. Pinned by a test that asserts
token_matches("", "")istrue— i.e.that documents why the empty case must never reach the handler — and then drives a server with an
empty token and asserts it refuses both
?token=absent and?token=empty.3 —
--auth-token ""satisfied the network guardConfirmed and fixed at parse time, with one correction: it does not literally admit everyone. It
admits anyone who sends
Authorization: Bearerwith nothing after it (orBasicdecoding tox:); a request with no Authorization header still gets 401. The practical upshot is the sameand arguably worse for being non-obvious:
--host 0.0.0.0 --auth-token ""binds to every interfacebehind a trivially guessable credential, past the one check whose entire job is to insist on
protection (
validate_network_authtestedauth_token.is_none(), andSome("")is notNone).cli.rs's newparse_auth_tokenrefuses an empty or whitespace-only value at argument-parse time.validate_network_authalso treats one as absent, becausehandle_webis a public function andthe guard must not depend on its one caller having been careful.
4 — The CORS token-lift (from the first review round)
Closed, as asked, the narrowest way that is still honest — and my own description was the argument
for closing it, so thank you for pushing back.
build_cors_layerallow-listedhttp://localhost:3000,http://127.0.0.1:3000and this server'sown origin whenever no
--auth-tokenwas passed. Without a token the middleware lets everythingthrough, so a cross-origin
fetchof/session/…that the browser permits reads the page, anddata-ws-tokenis in it.The grant's shape is worth recording, because it looks harmless until the port moves.
--portdefaults to 3000, so on a default run all three entries are this server and the allowance
means nothing. On any other port it hands
http://…:3000— a frontend dev server, or a page theoperator was talked into opening — read access to a chat page on, say,
:8080.--port 8080is oneof this command's documented invocations, and
scripts/test_web.shuses exactly it.I took option 1 and did not add an opt-in flag, because there is nothing left to opt into. The
two routes a cross-origin browser client could have wanted,
/api/sessionsand/api/sessions/{id}, are the ones this PR's first commit deleted. What remains is the page,/static/*, a static/api/health, and the WebSocket, which CORS does not govern. Nothing in therepository reads any of it from another origin —
scripts/test_web.shusescurl, which ignoresCORS entirely. An opt-in would therefore be an opt-in to the token leak and to nothing else. The
layer is kept rather than deleted so a preflight gets a definite answer from code that says why.
Measured before the fix:
http://localhost:3000 is told it may read the page holding the token.Sequencing: #264 must merge first
Added to the PR body as asked, and to SD-13. This PR's reach gate keys on a session id; #264
establishes that ids were being reissued after a delete and adds a high-water allocator. Until
#264 lands, a reissued id defeats the gate. Not a defect in this PR, but the merge order is not
interchangeable: #264, then this.
New tests (5), all fail-before
a_handshake_from_another_origin_is_refused_even_with_the_right_tokenhttp://localhost:3000 opened the socketthe_socket_token_is_required_on_every_path_and_never_empty?token=no_other_origin_may_read_the_page_that_carries_the_ws_tokenhttp://localhost:3000 is told it may read the page holding the tokenthe_page_still_serves_the_origin_it_is_served_froman_origin_is_this_server_only_when_it_matches_this_request_s_hostnull/file:/// a different portan_empty_auth_token_is_refused_and_does_not_satisfy_the_network_guardparse_auth_tokendoes not exist before the fixTwo notes on how these are driven, both of which cost me a wrong first attempt:
WebSocketUpgradeisextracted before the handler body runs, so a request without the upgrade headers is rejected
400 by the extractor and never reaches the rule under test.
handshakedoes not reuse the suite'sraw_request, which sendsConnection: closeand reads toEOF: a successful 101 leaves the connection open, so reading to EOF would hang. One bounded read.
The CORS test asserts on the header, not the body — the token is still in the page, because the
page needs it. What must not happen is a browser being told another origin may read that page.
Re-verification after this round
Clippy's only finding is still
too_many_linesonsend_prompt_turn(
crates/biorouter/src/agents/workspace_extension.rs:3406, 101/100) — red on a cleanmain, in afile this PR does not touch.
⚠ One trap worth recording for whoever runs these:
session::tests::cli_plan_mode_refuses_to_ship_a_private_transcript_elsewherefails without the isolated
HOME(it reads the real config). It passes under the incantationabove, and under the full suite. I hit it on an ad-hoc run and it reads exactly like a regression.
Still deliberately not closed
turn_reach→agent.replyTOCTOU, as agreed: same shape as the daemon'ssession_reach, Gate B still governs the model binding, a maintainer's call.biorouter weboutright. Still my recommendation, stated as strongly as I can:every hole in both review rounds lives in a page nothing else uses, on a command
biorouter servesuperseded. Eight distinct defects have now been found on this one surface across two reviews.
That is not a page that has been hardened; it is a page that keeps producing findings because
nobody uses it enough to notice. Deleting it closes all of them permanently and retires SD-13's
whole surface. I am not taking that decision, and I understand why you are not either.