fix: fail closed on the transferred upload chunk and on an unknown worker command - #1145
Conversation
WalkthroughThe worker command codec now validates descriptor fields and enum values before WASM binding. ChangesWorker boundary hardening
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The chunk reaches EngineHost.pushChunk by transfer, so the worker realm is its terminal owner. wasm-bindgen copies the view into WASM memory before it returns, so scrub the JS-side plaintext once the push settles, on the failure path too — mirroring the existing start secret scrub. Closes #1037
…odec buildCommand had no default arm, so an unknown kind fell out as undefined and only failed by accident of the wasm-bindgen glue. A known kind carrying a wrong-typed field was worse: wasm-bindgen coerced a numeric newName through USVString and renamed the node, rather than rejecting the command. Every field the builders read is now checked against the type the protocol declares, and the default arm binds the descriptor to never so a new command kind without a builder is a compile error. Closes #1051
e74a093 to
ca37edd
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/client/src/worker/engineHost.ts (1)
29-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReplace behavior narration with rationale.
These comments state behavior that the API and helper names already show. State the plaintext-lifetime reason at the public boundary. Remove the redundant test-helper comment.
packages/client/src/worker/engineHost.ts#L29-L29: State that transfer makes the worker the terminal owner, so it scrubs plaintext to reduce its lifetime.packages/client/src/worker/engineHost.test.ts#L45-L45: Remove the comment unless non-obvious test rationale is needed.Proposed documentation cleanup
- /** Takes ownership of `chunk`: the host scrubs the plaintext once it lands. */ + /** Transfer makes the worker the terminal owner, so it limits plaintext lifetime. */-/** A host whose WASM `pushChunk` hands the view it was given to `onPush`. */ function pushingHost(onPush: (chunk: Uint8Array) => Promise<void>): EngineHost {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/client/src/worker/engineHost.ts` at line 29, Update the public-boundary comment in packages/client/src/worker/engineHost.ts at line 29 to explain that transfer makes the worker the terminal owner, so it scrubs plaintext to reduce its lifetime. Remove the redundant comment in packages/client/src/worker/engineHost.test.ts at line 45 unless it provides non-obvious test rationale.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/client/src/worker/engineHost.ts`:
- Line 29: Update the public-boundary comment in
packages/client/src/worker/engineHost.ts at line 29 to explain that transfer
makes the worker the terminal owner, so it scrubs plaintext to reduce its
lifetime. Remove the redundant comment in
packages/client/src/worker/engineHost.test.ts at line 45 unless it provides
non-obvious test rationale.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 87de0388-92c2-49dd-ac77-0927a77ac73c
📒 Files selected for processing (4)
packages/client/src/worker/commandCodec.test.tspackages/client/src/worker/commandCodec.tspackages/client/src/worker/engineHost.test.tspackages/client/src/worker/engineHost.ts
The comment narrated the scrub; the load-bearing fact is that the transfer makes the host the terminal owner, which is what licenses it to zero a buffer at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nitpick dispositionPosted late — this PR merged without one. Verified against The review's single nitpick had two parts.
Nothing outstanding. |
Two fail-closed fixes in the engine worker, in one PR. Both live in
packages/client/src/worker/**, touch disjoint files, and share one review lens,so they are reviewed together rather than as two branches of a hundred lines each.
Part 1 — #1037, wipe the transferred upload chunk
EngineHost.pushChunkreceives the upload chunk by transfer, so the worker realm is its terminal owner — nothing upstream still holds the buffer. wasm-bindgen marshals theUint8Arrayview into aVec<u8>synchronously, beforepush_chunkreturns its promise, so once the call has been made the JS-side copy is pure residue.It was left for the collector. This wipes it.
What changed
EngineHostgrows one privatescrubbinghelper: it runs a call over a view of the buffer and fills the view with zeroes in afinally, so the failure path scrubs too.startalready did this inline for the login secret;pushChunknow shares the same helper rather than repeating the idiom.EngineHostLike.pushChunkstates the ownership transfer, so an implementer knows the buffer is the host's to scrub.The engine keeps its own copy in a
Zeroizing<Vec<u8>>for the duration of the push, so scrubbing after the call settles rather than between the marshal and the await opens no new window.Tests
packages/client/src/worker/engineHost.test.tsgains two cases: a chunk handed to the host is all zeroes after a resolved push and after a rejected one, with the WASM-side copy asserted to have seen the real bytes so the wipe cannot pass by wiping too early.Not in this PR
The crypto-privacy pass found the same residue class on the sending side:
EngineClient.pushChunkandCorrelatedTransport.requestdrop a chunk they refuse before any transfer, so it is never detached and never wiped. Those files are outside this batch's ownership — filed as #1155.Gates
pnpm typecheck0,pnpm test0,pnpm lint0,pnpm lint:tracker-refs0.Part 2 — #1051, fail closed on an unknown or wrong-typed command
buildCommandswitched overCommandDescriptorwith nodefaultarm, so an unknownkindfell out returningundefinedand only failed because the wasm-bindgen glue happened to reject it — fail-closed by accident, and inconsistent with the three sibling switches in the same file that already fail closed deliberately.The sharper case was a known kind carrying a wrong-typed field.
{ kind: 'rename', node: <16 bytes>, newName: 12345 }reachedwasm.Command.rename(nodeId, 12345)and wasm-bindgen'sUSVStringmarshalling coerced it, renaming the node to"12345"instead of rejecting the command. Reachable from a version-skewed follower build: the private-port relay validates the command shape and deliberately leaves field validation to the codec, so that division only holds if the codec enforces it.What changed
Uint8Array, strings a real string,opIda realbigint. The checkers takeunknown, because the descriptor arrives as plain data across a realm boundary and its static type is a claim, not a guarantee.nodeKindandpermissionno longer fall through toFolder/Writeon an unrecognised literal; they reject it. That was the same silent-coercion class, one layer down.defaultarm binds the descriptor toneverand throws. Verified by temporarily adding a seventeenth kind toCommandDescriptor:tscfails withType '{ kind: "probeNewKind"; … }' is not assignable to type 'never'at that binding, so a new kind cannot be added without a builder.Valid commands are unaffected — every check passes through the value it validated.
Tests
packages/client/src/worker/commandCodec.test.tsgains five refusal cases driven against a permissive fake whose every builder succeeds, so only the codec's own checks can reject: an unknown kind, a numericnewNameand a nullname, a string where a public key belongs and an array where a node id belongs, an unrecognisednodeKindandpermission, and a numberopId. A sixth asserts the accept side of both mirror enums —'folder'and'write', not only the literal each mapper tests first.Not in this PR
The byte and string arguments of
beginWrite,snapshot,downloadandopenContentStreamreach WASM from the same untrusted message with the same coercion, andpassArray8ToWasm0turns a 16-character string into sixteen zero bytes rather than rejecting it. That is #1154, which reuses the checkers this PR introduces.Gates
pnpm typecheck0,pnpm test0,pnpm lint0,pnpm lint:tracker-refs0.Closes #1037
Closes #1051
Note
Fail closed on invalid worker commands and transferred upload chunks
buildCommandin commandCodec.ts now strictly validates all descriptor fields, throwing descriptive errors for wrong-typed strings, non-Uint8Arraybyte fields, non-bigintopId, unknown node kinds, unknown permissions, and unknown command kinds instead of silently falling through or relying on wasm-bindgen coercion.EngineHost.pushChunkin engineHost.ts now zeros the providedArrayBufferafter the WASM call completes, using a newscrubbinghelper that guarantees zeroing even when the call rejects.buildCommandor unknown command kinds will now receive thrown errors rather than silent misbehavior.Macroscope summarized 45f85cc.
Summary by CodeRabbit