fix(companion): prevent withheld keys leaking through SSE on scrub failure - #31
Conversation
…ilure scrubEvent had a single try-catch around JSON.parse and scrub(). When scrub() threw (e.g. RangeError on deeply nested JSON), the catch treated it the same as "not JSON" and returned the original unscrubbed line — sending resumeCursors and sshAlias to the device. Split the catch: parse failure still passes through (not JSON, nothing to scrub), scrub failure replaces the data with an empty object. This mirrors the fix already applied in the proxy's JSON response path (proxy.ts lines 520–548), whose comment explicitly calls the scenario "not hypothetical". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The change correctly eliminates a confirmed withheld-key leak scenario in SSE and includes a focused regression test to prevent regressions.
Pull request overview
This PR fixes a security-sensitive edge case in the companion SSE scrubbing path: when JSON parsing succeeds but scrub() throws (e.g., extreme nesting causing RangeError), the scrubber must not fall back to forwarding the original data: line with withheld keys intact.
Changes:
- Split SSE
data:handling into separate parse vs. scrub error paths so scrub failures emitdata: {}instead of passing through. - Added a regression test ensuring withheld keys never appear in output when scrub fails due to deep nesting.
File summaries
| File | Description |
|---|---|
| companion/src/wire.ts | Separates JSON parse failures (pass through) from scrub failures (replace with empty JSON) to prevent withheld-key leaks over SSE. |
| companion/test/wire.test.ts | Adds a regression test that forces scrub failure via deep nesting and asserts withheld keys are not emitted. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| let parsed; | ||
| try { | ||
| return `data: ${JSON.stringify(scrub(JSON.parse(raw)))}`; | ||
| parsed = JSON.parse(raw); | ||
| } catch { | ||
| // not JSON: pass it through rather than dropping it. A frame this | ||
| // code does not understand is still the harness's to send. | ||
| return line; | ||
| } |
| let json = '{"resumeCursors":{"ghost":"leak-me"}}'; | ||
| for (let i = 0; i < 12_000; i++) json = `{"n":${json}}`; |
|
@DevChiniwala — thank you for this. It's a genuinely good first contribution, and this is the strongest of your three: a real fail-open on a security path, found by reasoning about the code rather than by pattern-matching a linter. Welcome. I reproduced the bug and the fix holds up. Detail below. The bug is realThe original wrapped It matters because if (Array.isArray(value)) return value.map(scrub);
...
out[key] = scrub(inner);Worth noting Reproduced
So the window where parse succeeds and scrub throws is wide and reliably reachable — your test's 12,000 sits well inside it with roughly 2x headroom, not on a knife edge. I'd flagged possible flakiness before measuring; there isn't any. Fail-closed is the right call
One optional follow-up (not blocking)This fixes the symptom correctly. The root cause is that No cycle-safety concern on this path, since StatusLooks correct to me and I'd like to take it. Two process notes:
Nice find. Please do send more. |
Summary
scrubEventincompanion/src/wire.tshad a single try-catch around bothJSON.parseandscrub(). Whenscrub()threw —scrub()recurses, and a body nested a few thousand deep produces aRangeErrorthatJSON.parsehandles fine — the catch treated it as "not JSON" and returned the original unscrubbed line. That line still containedresumeCursorsandsshAlias: the keys this file exists to withhold from devices.The proxy's JSON response path already handles this exact scenario (proxy.ts lines 520–548), with a comment calling it "not hypothetical." The SSE path had the same exposure but lacked the split.
What changed
data: {}(JSON but unscrubable, must not go through unscrubbed).resumeCursorsinside, feeds it through the SSE scrubber, and asserts the withheld keys do not appear in the output. The test fails on the old code and passes on the fix.Test plan
replaces unscrubable JSON data instead of leaking withheld keys— confirmed to fail on the old code and pass on the fixpnpm typecheckcleanpnpm check:brandclean