Skip to content

Catch sensitive values split across response chunks - #981

Open
philmillman wants to merge 2 commits into
mainfrom
fix-split-chunk-leak-scan
Open

Catch sensitive values split across response chunks#981
philmillman wants to merge 2 commits into
mainfrom
fix-split-chunk-leak-scan

Conversation

@philmillman

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 writeend, writewrite, 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 100ms unref'd timer so a stream that pauses mid-lookalike (SSE, long-poll) is not stalled. 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.
  • end() now redacts under redactInsteadOfThrow instead of always throwing, matching write(). This is required for the split writeend 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

9 new detection tests across the three runtime test files, each confirmed to fail against the unpatched source and pass with the fix. Regression guards cover held-back text delivered intact, multi-byte splits, the timed flush arriving before end(), 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 1.16.0 → 1.16.1

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 25.2 KB (+0.5%)

Metric main This PR Δ
Total dist 5007.0 KB 5032.1 KB +25.2 KB (+0.5%)
JS 1716.6 KB 1727.7 KB +11.2 KB (+0.7%)
Sourcemaps 3213.8 KB 3227.8 KB +14.0 KB (+0.4%)
Type defs 76.6 KB 76.6 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

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

commit: 6e38bde

@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𝕏

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.

1 participant