fix(security): close S-01 and S-02, found by falsifying the fixes (Session 7) - #61
Merged
Merged
Conversation
…ssion 7) Session 7 (§12) was the first falsification pass over the *remediation* run by an agent that did not write it. It confirmed eleven of the fourteen fixes and found four defects. These are the two that were demonstrably broken rather than merely incomplete, and they are the same mistake twice: each earlier fix was written against the route where its finding reproduced, and each left a sibling route untouched. S-01 — the gateway creates a session only on a route that uses one. The credential check hoisted in the previous round is gated on `isApiRoute`; `getOrCreateSession` ran above the route dispatch and was not. So `POST /v1/anything` and `GET /nope` answered 404 and minted a session on the way there with no credential of any kind: 20 unauthenticated POSTs to an unknown endpoint produced 20 sessions, 120 GETs naming chosen ids over one connection produced 100 — the store's whole maxSessions cap. It is reachable from a web page, which is the attacker the origin gate exists for. A no-cors GET carries no Origin header at all, so V-02's refusal never sees it and the Host check passes because the browser really is talking to 127.0.0.1. Driven from a page on another port: 400 requests, 400 connections, 0 carrying Origin, sessionCount 100, and a victim session seeded beforehand evicted along with its content. After the fix the same page leaves sessionCount at 1 — the victim's, content intact. Unchanged: a local process still passes hasAuthHeaders with any string, which is the exec trust boundary (audit C3) and not something this closes. S-02 — the CLI's fallback renderer escapes its envelope label too. F-06 escaped \r and \n in core/render's itemLabel; renderFallbackBytes emits the same header over each file's original bytes on the fail-open path and built it from the raw path, so a filename containing a newline still forged a header there. Measured on ext4: 3 headers optimized, 4 on fallback, the extra one followed by the attacker's chosen line — and the attacker forces the fallback with one file of their own that is not valid UTF-8. The escaping is now an exported escapeDelimiterLabel in core/render that both renderers call. `file.bytes` still passes through untouched. README needed no edit: its claim that a crafted name cannot introduce a header line is true again, on both routes. Written test-first. Both were reproduced as failing unit cases before either fix existed — `expected 20 to be +0`, `expected 100 to be +0`, and `expected [ Array(4) ] to have a length of 3 but got 4`. Nine cases now stand, two of which fail if a fix over-reaches: a credentialed request must still get a session, and the fallback stream must still carry original bytes. 99 files / 927 passed, 2 skipped. Typecheck, lint and build clean. No corpus run, deliberately: S-01 is off the optimize route, and no corpus file has a newline in its name, so byte-identical would have measured nothing. S-03 and S-04 stay open and are recorded as such. DECISIONS §73; report §12.8. Co-Authored-By: Claude Opus 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.
Session 7 (§12 of
docs/security-review-2026-08-30.md) was the first falsification pass over the remediation run by an agent that did not write it. It confirmed eleven of the fourteen fixes and found four defects. This closes the two that were demonstrably broken rather than merely incomplete.They are the same mistake twice: each earlier fix was written against the route where its finding reproduced, and each left a sibling route untouched. Neither is subtle logic — the logic is correct everywhere it runs.
S-01 — the gateway creates a session only on a route that uses one
The credential check hoisted in the previous round is gated on
isApiRoute;getOrCreateSessionran above the route dispatch and was not.POST /v1/anythingandGET /nopeanswered 404 and minted a session on the way there, with no credential of any kind.Reachable from a web page, which is the attacker the origin gate exists for. A no-cors GET carries no
Originheader at all — the Fetch spec appends one only for CORS-tainted requests or non-GET/HEAD methods — so V-02's refusal never sees it, and theHostcheck passes because the browser really is talking to127.0.0.1.Driven from a page on another port against a live gateway, before:
After — same page, same 400 requests:
sessionCount1, and it is the victim's, content intact.The session is now opened inside the two API branches and the 404 reports none.
ProxyRequestResult.sessionhas been optional since the hoist and nothing reads it. Unchanged: a local process still passeshasAuthHeaderswith any string and may still name any session id — that is theexectrust boundary (audit C3), not something this closes.S-02 — the CLI's fallback renderer escapes its label too
F-06 escaped
\rand\nincore/render'sitemLabel.renderFallbackBytesincli/main.tsemits the same==> … <==header over each file's original bytes on the fail-open path, and built it by interpolatingfile.pathraw. Its own comment said "under the header the renderer emits", which stopped being true when the renderer started escaping.Measured on ext4, one directory, two runs of the shipped binary:
The attacker controls the trigger as well as the payload — one file of their own containing invalid UTF-8 forces the fallback through
inputNotRepresentable.The escaping is now an exported
escapeDelimiterLabelincore/render, next to the delimiters it protects, called by both renderers. Only the header is escaped;file.bytespasses through untouched, because emitting the caller's original bytes is why that path exists (DECISIONS §35). Re-run on ext4 after: one header per file, all absolute, the crafted name escaped onto a single line.The README needed no edit, which is the point. Its claim that "a crafted name cannot introduce a header line" was false on the fallback route when §12 was written. It is true again, on both.
Written test-first
Both defects were reproduced as failing unit cases before either fix existed:
Nine cases now stand in
test/unit/security-review-findings.test.ts, two of which fail if a fix over-reaches: a credentialed request on an API route must still get a session, and the fallback stream must still carry each file's original bytes.Verification
Still open
S-03 (V8's
JSON.parsemessage quoting ~15 bytes of payload intotrace.fallbackReason, the field F-05 cleaned) and S-04 (the §6.3 upstream guard validating a base URL thatfetchis then free to redirect away from, carryingx-api-key) remain open and are recorded as such. Neither is a fix that fails to do what it claims, which is what separated them from these two.Docs: DECISIONS §73, CHANGELOG
[Unreleased], report §12.8.🤖 Generated with Claude Code