Skip to content

fix: validate worker request fields and wipe a refused upload chunk - #1241

Merged
FSM1 merged 4 commits into
mainfrom
feat/client-request-validation-and-refused-chunk-wipe
Aug 11, 2026
Merged

fix: validate worker request fields and wipe a refused upload chunk#1241
FSM1 merged 4 commits into
mainfrom
feat/client-request-validation-and-refused-chunk-wipe

Conversation

@FSM1

@FSM1 FSM1 commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Two packages/client boundary fixes: the worker validates the request fields it reads instead of letting wasm-bindgen coerce them, and the sending side scrubs an upload chunk it refuses before any transfer.

Validate the worker's read and write request fields

EngineHost.beginWrite, snapshot, download, openContentStream and readStream read their arguments straight off the worker message, exactly as command did before its codec gained checkers. wasm-bindgen coerces rather than throws, so a 16-character string reached NodeId::from_bytes as sixteen zero bytes, and a string or NaN ToInt32'd into a byte count — a malformed request became a different valid request rather than a refusal.

  • The command codec's field checkers are now exported and applied to every field the host reads: WriteTarget.parent/name/node, beginWrite size, snapshot folder, download/openContentStream node, readStream offset and length. Byte fields go through nodeId, which is the bytes checker composed with NodeId.fromBytes — the same check, spelled once.
  • Counts get count: a non-negative safe integer, since typeof alone still admits NaN, 1.5 and -1.
  • record refuses a WriteTarget that is not an object, so the variant discriminator cannot throw a bare TypeError.
  • buffer guards the two transferred payloads (start's secret, pushChunk's chunk), which went straight into new Uint8Array(payload) — a coercion with a sharper edge than the rest, since a Uint8Array there is copied, so the scrub afterwards zeroes the copy and leaves the sender's plaintext.
  • beginWrite/openContentStream became async, so a refused field rejects rather than throwing synchronously — the rest of the surface already did.
  • The diagnostic reads invalid request field … rather than invalid command field …: the same checkers now guard requests that carry no command.

Wipe an upload chunk the sender refuses before it is transferred

A chunk refused before any transfer is never detached, so the refusing frame is its terminal owner and nothing downstream will ever scrub it (AGENTS.md rule 7). A failed-over or closing tab was leaving up to one chunk of file plaintext in its heap indefinitely.

  • EngineClient.pushChunk scrubs the chunk its unknown-handle refusal declines to forward, mirroring how start handles a secret it declines.

  • CorrelatedTransport.request scrubs what the send would have transferred on every route to a pre-send rejection: an already-latched terminal error, one latched while the readiness gate was awaited, a rejected gate, and a throwing send. A transferred buffer reads as empty, so a send that did run leaves the scrub a no-op — and the happy path is asserted to keep its bytes, since wiping a buffer the receiver is about to seal would corrupt the upload.

  • EngineClient.start refused a closed client before scrubbing the secret it had already declined to forward — the same refuse-before-transfer shape, found by the security pass.

  • EngineTransport now states outright that a buffer it takes is consumed on every outcome, transferred away or scrubbed in place. A retryable rejection (leader changed; retry) hands back a zeroed buffer, so a retry must re-read its source; both upload callers already do, and the contract was the only thing left implicit.

Wiring the two transports to declare what they transfer is 3 lines outside the issues' stated files (transport.ts start/command/pushChunk, broadcastTransport.ts overPort); without it the base-class scrub would never see a real chunk.

Verification

Each behaviour was broken deliberately and the failing test recorded, then restored — 20 mutants, 20 caught, including an over-wipe mutant that scrubs after a successful send and two that undo the transport wiring. The wipes are asserted on buffer contents after the rejection, never on a spy.

Closes #1154
Closes #1155

Note

Validate worker request fields and zero-fill refused upload chunks

  • Adds upfront type and range validation to EngineHost methods (start, beginWrite, pushChunk, commitWrite, abortWrite, readStream, closeStream, snapshot, download, openContentStream) using new helpers (buffer, count, minted, record, nodeId) in commandCodec.ts.
  • Extends CorrelatedTransport.request to accept a transfer list and zero-fill any ArrayBuffer entries on pre-send failure paths (terminal error, gate rejection, or send exception); successful transfers are unaffected.
  • EngineClient.pushChunk now scrubs the chunk buffer when the write handle is unknown or stale; EngineClient.start scrubs the secret when called on a closed or non-leader client.
  • Error messages for malformed fields are normalized to 'invalid request field' across the worker codec.
  • Behavioral Change: callers must not retry with the same buffer after a refused call — buffers are consumed (zeroed or transferred) on every outcome.

Macroscope summarized b718e2f.

Summary by CodeRabbit

  • Security

    • Sensitive secrets and upload data are now cleared when operations are rejected, fail, or occur after shutdown.
    • Transferred buffers are scrubbed before transmission when dispatch cannot proceed.
  • Bug Fixes

    • Improved handling of client and transport operations after teardown or closure.
    • Invalid requests are rejected earlier with clearer validation errors.
  • Validation

    • Added stricter checks for buffers, text, records, identifiers, sizes, and numeric ranges before processing.

The engine host read `beginWrite`, `snapshot`, `download`,
`openContentStream` and `readStream` arguments straight off an untrusted
worker message. wasm-bindgen coerces rather than refuses, so a
16-character string reached `NodeId::from_bytes` as sixteen zero bytes and
a string or NaN ToInt32'd into a byte count: a malformed request became a
valid one against a different node or window. Every field the host reads
now passes the codec's checkers, with a finite non-negative check for the
counts.

The sending side also dropped an upload chunk it refused before any
transfer, leaving readable plaintext in a failed-over or closing tab's
heap. `EngineClient.pushChunk` now scrubs the chunk its unknown-handle
refusal never hands on, and `CorrelatedTransport.request` scrubs what a
send would have transferred on every route to a pre-send rejection — a
terminal error, a refused readiness gate, or a throwing send.

Closes #1154
Closes #1155
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The client now scrubs rejected secret and chunk buffers. Transport layers propagate transfer lists through dispatch. Worker request fields use strict type validation before WASM calls.

Changes

Security and boundary validation

Layer / File(s) Summary
Worker request validation
packages/client/src/worker/commandCodec.ts, packages/client/src/worker/engineHost.ts, packages/client/src/worker/*test.ts
Exported validators reject invalid records, buffers, text, counts, and node IDs. EngineHost validates request fields before forwarding them to WASM.
Transfer-buffer scrubbing
packages/client/src/correlatedTransport.ts, packages/client/src/transport.ts, packages/client/src/broadcastTransport.ts, packages/client/src/*transport.test.ts
Transfer lists now pass through request dispatch. ArrayBuffer values are scrubbed when requests reject before transmission.
Client rejection handling
packages/client/src/engineClient.ts, packages/client/src/engineClient.test.ts
EngineClient clears secrets and chunks when operations reject because the client is closed, non-leader, or has an unknown write handle.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • FSM1/cipher-box#728: Related LocalTransport, commandCodec, and engineHost validation and scrubbing changes.
  • FSM1/cipher-box#733: Related transport, EngineClient, and transferable-buffer handling.
  • FSM1/cipher-box#1145: Related EngineHost and commandCodec validation and transfer-buffer scrubbing.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy #1154 validation requirements and #1155 buffer-scrubbing requirements, with matching implementation and test coverage.
Out of Scope Changes check ✅ Passed The changes remain within the linked issue scope, including related transport ownership documentation and secret-scrubbing coverage.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: worker request validation and scrubbing refused upload chunks.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/client-request-validation-and-refused-chunk-wipe

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

FSM1 added 2 commits August 9, 2026 23:25
Name the transfer scrub `wipeTransfer` so it no longer collides with the
relay's payload-shaped `wipeCarried`, state each ownership rationale once
at its home, and cover the transport wiring that declares what a send
would have moved: without a test, undoing either `LocalTransport`
argument left every suite green.
Security-review follow-ups on the same two boundaries. `start` and
`pushChunk` took their payload straight into `new Uint8Array(buffer)`,
which coerces: a view is copied rather than referenced, so the scrub
afterwards zeroes the copy and leaves the sender's plaintext, and a
transferable that is not an `ArrayBuffer` yields a zero-length push. Both
now go through a buffer check like every other field.

`EngineClient.start` refused a closed client before scrubbing the secret
it had already decided not to forward — the same refuse-before-transfer
shape as the chunk. The seam contract now says outright that a buffer is
consumed on every outcome, so a retry re-reads its source rather than
re-sending one a retryable rejection scrubbed.
@FSM1
FSM1 marked this pull request as ready for review August 10, 2026 20:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 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.

Inline comments:
In `@packages/client/src/correlatedTransport.ts`:
- Around line 75-85: Update wipeTransfer to detect ArrayBuffer values without
relying on instanceof, so buffers from other JavaScript realms are wiped before
the existing Uint8Array fill operation. Preserve the current handling for
undefined transfers, already-empty buffers, and other transferable types.

In `@packages/client/src/worker/commandCodec.ts`:
- Around line 31-45: Update buildCommand to validate the command envelope with
record and validate its kind with text before reading kind or entering the
switch, so null, primitive, and non-string values consistently raise invalid
request field errors. Add regression coverage confirming malformed envelopes do
not invoke any WASM command factory.

In `@packages/client/src/worker/engineHost.ts`:
- Around line 138-139: Validate every WriteHandle and StreamHandle with a shared
runtime bigint validator before invoking WASM in pushChunk, commitWrite,
abortWrite, and closeStream. Ensure malformed handles reject at the worker
boundary without reaching the WASM call, and add behavior tests covering invalid
handles for each affected operation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7895dcdf-b6db-4bb6-bfb5-804107b77074

📥 Commits

Reviewing files that changed from the base of the PR and between 4128ccc and 816668c.

📒 Files selected for processing (12)
  • packages/client/src/broadcastTransport.test.ts
  • packages/client/src/broadcastTransport.ts
  • packages/client/src/correlatedTransport.test.ts
  • packages/client/src/correlatedTransport.ts
  • packages/client/src/engineClient.test.ts
  • packages/client/src/engineClient.ts
  • packages/client/src/transport.test.ts
  • packages/client/src/transport.ts
  • packages/client/src/worker/commandCodec.test.ts
  • packages/client/src/worker/commandCodec.ts
  • packages/client/src/worker/engineHost.test.ts
  • packages/client/src/worker/engineHost.ts

Comment thread packages/client/src/correlatedTransport.ts
Comment thread packages/client/src/worker/commandCodec.ts
Comment thread packages/client/src/worker/engineHost.ts Outdated
@FSM1
FSM1 marked this pull request as draft August 10, 2026 20:54
… worker

Three fields reached past the boundary this PR exists to guard.

wipeTransfer branded transferables with instanceof ArrayBuffer, which is
false for one minted in another realm, so a secret arriving from a worker
or a frame skipped the scrub. Branded by the byteLength getter now, which
answers across realms and returns null for everything else.

buildCommand read descriptor.kind before validating the envelope, so a
null or primitive command answered with a TypeError or an unknown-kind
error naming undefined, rather than the invalid-field refusal every other
malformed input gets.

Write and stream handles crossed to WASM unchecked while their sibling
fields were validated. minted() is the shared bigint check, applied at
pushChunk, commitWrite, abortWrite, readStream and closeStream.
commitWrite becomes async so its refusal rejects like the other four
rather than throwing synchronously out of the call.

FSM1 commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Review disposition — CodeRabbit on 816668c

3 actionable, all Security & Privacy / Major, no nitpicks and no outside-diff notes. All three verified and taken in b718e2f. This was a well-aimed review: each one was a field reaching past the boundary this PR exists to guard.

Finding Disposition
wipeTransfer brands with instanceof ArrayBuffer, false across realms Taken. Reproduced the realm behaviour locally rather than trusting the script: instanceof false, getter brand 4, null for {}/null/MessagePort, and the fill does zero foreign bytes. Now branded by the byteLength getter. Chose it over Object.prototype.toString because Symbol.toStringTag is writable — a buffer could be made to answer something else and skip the scrub, the wrong way to fail on a plaintext-clearing path.
buildCommand reads kind before validating the envelope Taken. An inconsistency with this file's own header, which says every field the worker reads passes a checker — beginWrite does, the envelope did not, and serve.ts:53 hands it straight from the wire. null gave a TypeError; a primitive or missing kind gave unknown command kind: undefined. Both answer invalid request field … now.
write and stream handles forwarded to WASM unchecked Taken at all five sites. pushChunk validated chunk but not handle; readStream validated offset/length but not handle; commitWrite, abortWrite, closeStream validated nothing. One shared minted() checker now, replacing the private opId so there is one bigint check rather than two.

One thing the review did not name, found while fixing it

commitWrite was the only one of the five not async, so its refusal threw synchronously out of the call while the other four rejected. A boundary should refuse in one shape; it is async now. This surfaced only because the new test asserted rejects uniformly and commitWrite alone failed — it was not visible from reading the diff.

Coverage added

7 cases, each verified as a true negative control (reverting the guard fails it, restoring it passes):

  • 5 × refuses a {pushChunk,commitWrite,abortWrite,readStream,closeStream} carrying a handle the engine never minted — each also asserts calls is empty, so an empty WASM call log is the evidence none ran
  • refuses an envelope that is not a command before it reads a kind off itnull, a number, a string, a non-string kind, and a missing kind
  • wipes a chunk minted in another realm, which instanceof does not answer for — a real foreign buffer via node:vm, asserting instanceof is false for it and that the bytes end zeroed

Gates on b718e2f

pnpm lint 0 · pnpm typecheck 0 · pnpm --filter @cipherbox/client test 0 (29 files, 441 tests) · pnpm lint:tracker-refs 0.


Generated by Claude Code

@FSM1
FSM1 marked this pull request as ready for review August 11, 2026 00:09
@FSM1
FSM1 merged commit 4237a79 into main Aug 11, 2026
35 checks passed
@FSM1
FSM1 deleted the feat/client-request-validation-and-refused-chunk-wipe branch August 11, 2026 10:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant