Skip to content

Catch sensitive values split across response chunks - #981

Merged
theoephraim merged 7 commits into
mainfrom
fix-split-chunk-leak-scan
Aug 25, 2026
Merged

theoephraim merged 7 commits into
mainfrom
fix-split-chunk-leak-scan

Conversation

@philmillman

@philmillman philmillman commented Aug 4, 2026 •

Copy link
Copy Markdown
Member

Varlock's response leak scanner checked each ServerResponse.write() / end() chunk in isolation, and detection is a substring match against complete values. A sensitive value split across a chunk boundary was therefore present in neither scan and went out to the client, even though the same value in a single chunk was blocked. Streaming SSR flushes at arbitrary points, so this is the normal case for the workload the patch was written for, not an edge case.

The application controls where chunks break, not a remote caller, so this is a gap in a safety net rather than a new way to reach secrets. It still means the net silently fails open on its main use case.

What changed

  • Each chunk is scanned with the tail of the previous one, so a value spanning a boundary matches. Covers write→end, write→write, and the gzip/br/zstd delta path.
  • Trailing text that looks like the start of a sensitive value is held back rather than sent, and prepended to the next chunk, so a split value can be redacted instead of half-delivered. Held-back text is flushed on a 15ms unref'd timer so a stream that pauses mid-lookalike (SSE, long-poll) is not stalled; fragments that split a secret arrive within the same tick or a few ms, so the short window covers them while keeping the worst-case stall imperceptible. This reuses getRedactionHoldbackLength, already used by CLI output redaction.
  • Same carry-over in the ReadableStream scanner in env.ts, which the edge Response patch and the Cloudflare integration use.

Fixed alongside it, in the same code paths:

  • end() never decompressed compressed chunks, so a secret in a final compressed chunk was not scanned at all.
  • Binary chunks now decode with a per-response streaming TextDecoder. A multi-byte character split across chunks previously decoded to replacement characters, a second source of missed matches and a corruption risk once chunks get rewritten. Once a chunk ends mid-character, the response switches to emitting re-encoded decoded text, so the held tail bytes can't be sent twice when a later chunk gets rewritten.
  • end() now redacts under redactInsteadOfThrow instead of always throwing, matching write(). This is required for the split write→end case to be redactable, and it replaces the hung request the old code left behind (there was a TODO on that line about it). It affects Next.js dev only; production and every other integration still throw.
  • When redaction shortens the body, Content-Length is recomputed. Next.js sets Content-Length for non-streamed payloads and sends them in a single end(), so a stale length left the client waiting on bytes that never arrived.

Clean responses stay byte-for-byte identical: the outgoing chunk is only rewritten when something was actually redacted or held back.

Testing

17 new tests across the three runtime test files, each confirmed to fail against the unpatched source and pass with the fix. A seeded fuzz suite (30 chunkings per property over a real socket) additionally asserts that clean responses arrive byte-for-byte intact, throw mode never delivers the secret, and redact mode produces exactly the redacted text; it reproduces every byte-integrity bug fixed during review when run against the earlier revisions. Regression guards cover held-back text delivered intact, multi-byte splits, the timed flush arriving before end(), the write callback firing when a whole chunk is withheld, bare end() flushing withheld text, detection still working after a timed flush, Content-Length correction, and text that merely starts like a secret not being flagged.

Full framework test suite run locally: 670 passed across Astro 5/6/7, Next.js 14/15/16 (webpack + turbopack), Vite 5/6/7/8, Cloudflare, TanStack Start, SvelteKit, Expo, and vanilla-node. Two suites hit harness beforeAll timeouts under concurrent load and passed on a targeted re-run (200/200).

Reported by @7thParkk.

The ServerResponse patch scanned each write()/end() chunk on its own, and
detection is a substring match against complete values, so a secret split
across a chunk boundary passed both scans. Streaming SSR flushes at
arbitrary points, so this is the normal case rather than an edge case.

Each chunk is now scanned with the tail of the previous one, and trailing
text that looks like the start of a sensitive value is held back until the
next chunk (flushed on a short timer so a paused stream is not stalled).
The same carry-over is applied to the ReadableStream scanner used by the
edge/Response paths.

Also fixed alongside it:
- end() now handles compressed chunks, which were never decompressed or scanned
- binary chunks decode with a streaming decoder, so a multi-byte character
  split across chunks no longer decodes to replacement characters
- end() redacts under redactInsteadOfThrow instead of always throwing, and
  recomputes Content-Length when redaction shortens the body
@github-actions

github-actions Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

bumpy-frog

The changes in this PR will be included in the next version bump.

patch Patch releases

  • @varlock/native-helper-darwin 1.17.1 → 1.17.2
  • @varlock/native-helper-linux-arm64 1.17.1 → 1.17.2
  • @varlock/native-helper-linux-x64 1.17.1 → 1.17.2
  • @varlock/native-helper-win32-x64 1.17.1 → 1.17.2
  • varlock 1.17.1 → 1.17.2

Bump files in this PR

Click here if you want to add another bump file to this PR


This comment is maintained by bumpy.

@github-actions

github-actions Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

📦 Bundle size

⚠️ grows the bundle by 34.2 KB (+0.8%)

Metric main This PR Δ
Total dist 4049.8 KB 4084.0 KB +34.2 KB (+0.8%)
JS 1566.7 KB 1583.5 KB +16.8 KB (+1.1%)
Sourcemaps 2398.6 KB 2416.0 KB +17.4 KB (+0.7%)
Type defs 84.6 KB 84.6 KB —
Other 0.0 KB 0.0 KB —

dist/ only; native binaries are versioned separately and not counted here.

@philmillman
philmillman requested a review from theoephraim August 4, 2026 19:48

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Compressed Unicode secrets can still bypass the new boundary scan, and split redaction can leave an invalid Content-Length that hangs the client. Both paths need correction before merge.

Reviewed changes across the Node and Web streaming leak scanners, response redaction behavior, focused runtime tests, documentation, and release metadata.

  • Cross-chunk scanning: Adds per-response carry and pending state for Node responses, plus carry and streaming UTF-8 decoding for Web ReadableStream bodies.
  • Response redaction: Holds possible secret prefixes briefly, redacts final chunks in Next.js development, and updates Content-Length for rewritten final bodies when possible.
  • Compressed responses: Accumulates compressed chunks, scans newly decoded output, and adds final compressed-chunk scanning.
  • Regression coverage: Adds ASCII split-boundary, gzip, multibyte pass-through, timed-flush, redaction, and response-framing tests.
  • User-facing guidance: Documents cross-chunk detection and the 100 ms holdback, with a patch release entry.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using azure/gpt-5.6-sol | 𝕏

Comment thread packages/varlock/src/runtime/patch-server-response.ts Outdated
Comment thread packages/varlock/src/runtime/patch-server-response.ts
@pkg-pr-new

pkg-pr-new Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

varlock

npm i https://pkg.pr.new/varlock@981

@varlock/native-helper-darwin

npm i https://pkg.pr.new/@varlock/native-helper-darwin@981

@varlock/native-helper-linux-arm64

npm i https://pkg.pr.new/@varlock/native-helper-linux-arm64@981

@varlock/native-helper-linux-x64

npm i https://pkg.pr.new/@varlock/native-helper-linux-x64@981

@varlock/native-helper-win32-x64

npm i https://pkg.pr.new/@varlock/native-helper-win32-x64@981

commit: 9359442

@pullfrog

pullfrog Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Addressed and resolved both review threads in commit 6e38bdea. Compressed response scanning now uses byte offsets with streaming UTF-8 decoding, and redaction mode switches split writes away from fixed Content-Length framing; all 49 focused response scanner tests and bun run lint:fix pass.

Task list (5/5 completed)
  • Check out PR 981 and inspect both review threads against the implementation
  • Implement complete minimal fixes for valid feedback
  • Run focused tests and lint, then review the final diff
  • Commit and push the fixes
  • Reply to and resolve addressed review threads

Pullfrog  | View workflow run | via Pullfrog | Using azure/gpt-5.6-sol | 𝕏

pullfrog Bot and others added 2 commits August 4, 2026 19:57
Fragments that split a secret arrive within the same tick or a few ms, so a
15ms flush valve covers them the same as 100ms while keeping the worst-case
stall of a lookalike tail imperceptible.

New tests: callback fires when a whole chunk is withheld, bare end() flushes
withheld text, and a secret completed after the timed flush is still caught
via carry.
@theoephraim
theoephraim force-pushed the fix-split-chunk-leak-scan branch from e147fed to 0c9b770 Compare August 25, 2026 18:39

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes since the prior Pullfrog review, focused on the revised holdback timing and added regression coverage.

  • Shortened holdback window: Reduced the pending prefix flush timeout from 100 ms to 15 ms while retaining carry-based detection after a timed flush.
  • Expanded holdback coverage: Added tests for detection after timer expiry, callback completion when an entire write is withheld, and pending text delivery through a bare end().
  • Updated documentation: Aligned the documented maximum response delay with the new 15 ms timeout.

Pullfrog  | View workflow run | Using azure/gpt-5.6-sol | 𝕏

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
varlock-website 0c9b770 Commit Preview URL

Branch Preview URL
Aug 25 2026, 06:43 PM

Once a binary chunk ends mid-character the streaming decoder holds its tail
bytes, but the raw chunk (partial bytes included) was still passed through.
If a later chunk then got rewritten (redaction or holdback), re-encoding the
decoded text emitted those held bytes a second time. Now the response switches
to emitting re-encoded text from the first mid-character split onward.

Also logs a debug line when a compressed end() chunk fails to decode instead
of silently skipping the scan.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The new re-encoding path can mutate clean responses for mixed chunk types and malformed UTF-8, so the byte-stream integrity regressions should be fixed before merge.

Reviewed changes since the prior Pullfrog review, focused on the binary decoder-tail correction and its new regression coverage.

  • Added persistent re-encoding: Switched subsequent binary output to re-encoded decoded text after a chunk ends inside a UTF-8 character, preventing a held lead byte from being emitted twice during later redaction.
  • Added redaction regression coverage: Verified that a valid multibyte split before a redacted secret produces the intended character without duplicate or replacement bytes.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using azure/gpt-5.6-sol | 𝕏

Comment thread packages/varlock/src/runtime/patch-server-response.ts Outdated
Comment thread packages/varlock/src/runtime/patch-server-response.ts Outdated
Address two review findings on the re-encode fix:

- The trailing-byte check used broad bitmasks, so invalid UTF-8 leads
  (0xC0/0xC1, 0xF5+) and out-of-range second bytes counted as held even
  though TextDecoder replaces them immediately. That flipped reEncode and
  mutated malformed-but-clean bytes. The check now mirrors the decoder's
  actual hold conditions (verified against TextDecoder behavior).

- A response switching from binary to string chunks while the decoder held
  tail bytes dropped or reordered those bytes. String writes now flush the
  held bytes in place (as a replacement char, matching what a client would
  have decoded) and rewrite comparisons use the original chunk, so a flushed
  tail is never silently discarded in write() or end().

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The mixed binary-to-string path still duplicates held bytes when the binary chunk has no decoded prefix, so one response-integrity regression remains before merge.

Reviewed changes since the prior Pullfrog review, focused on the UTF-8 decoder-state corrections and their regression coverage.

  • Tightened incomplete-sequence detection: Restricted re-encoding to valid UTF-8 leads and valid second-byte ranges so malformed lead bytes continue passing through unchanged.
  • Flushed decoder state before strings: Prepended a pending decoder replacement before subsequent string writes and final chunks, then realigned the raw-output comparison.
  • Expanded socket coverage: Added binary-to-string and invalid-lead-byte response tests.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using azure/gpt-5.6-sol | 𝕏

Comment thread packages/varlock/src/runtime/patch-server-response.ts
A binary write consisting only of an incomplete UTF-8 sequence decodes to an
empty string, so the re-encode rewrite was skipped and the raw bytes went out
anyway - then the flush on the next string chunk emitted the same held bytes
again as a replacement char. Such a chunk is now reported as written without
sending anything, matching the withheld-text path; its bytes go out once the
character completes or the held tail is flushed.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes since the prior Pullfrog review, focused on the remaining decoder-only binary chunk correction and its regression coverage.

  • Withheld decoder-only chunks: Prevented a binary chunk made entirely of an incomplete UTF-8 sequence from being sent raw before the decoder later flushes or completes it.
  • Added socket regression coverage: Verified that an incomplete lead-byte chunk followed by a string end() produces one replacement character rather than duplicated malformed output.

Pullfrog  | View workflow run | Using azure/gpt-5.6-sol | 𝕏

Property tests that split fixtures into random mixes of string and Buffer
chunks (including mid-character byte splits) across 30 seeds each, asserting
three invariants over a real socket: clean responses arrive byte-for-byte
intact, throw mode never delivers the secret, and redact mode produces
exactly the redacted text. A deterministic seeded PRNG makes any failure
replayable.

Verified against prior revisions: these tests catch the byte-duplication,
mixed-chunk, and wholly-held-chunk bugs fixed in the last three commits.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes since the prior Pullfrog review, focused on the new deterministic fuzz coverage for response chunking.

  • Added reproducible chunk fuzzing: Introduced a seeded generator that mixes string and binary chunks, including byte splits inside multibyte characters and both supported response-ending paths.
  • Verified scanner invariants over sockets: Added exact assertions that clean responses remain byte-for-byte intact, throw mode never delivers the secret, and redaction mode emits precisely the expected redacted body across 30 chunkings.

Pullfrog  | View workflow run | Using azure/gpt-5.6-sol | 𝕏

@theoephraim
theoephraim merged commit 14e9a57 into main Aug 25, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants