Skip to content

Commit 0c9b770

Browse files
committed
shorten holdback flush to 15ms and cover remaining holdback paths
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.
1 parent 6e38bde commit 0c9b770

3 files changed

Lines changed: 46 additions & 6 deletions

File tree

packages/varlock-website/src/content/docs/guides/secrets.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ This works by patching:
188188
- **Node.js `ServerResponse`**: intercepts `write()` and `end()` calls, scanning text and JSON response bodies (including gzip-compressed responses)
189189
- **Global `Response` constructor**: intercepts the `Response` class used in edge runtimes (e.g., Cloudflare Workers), scanning bodies passed to the constructor and `Response.json()`
190190

191-
Streamed responses are scanned across chunk boundaries, so a sensitive value that gets split between two writes is still detected. When the end of a chunk looks like the start of a sensitive value, that trailing text is held back for up to 100ms (or until the next chunk arrives) so the full value can be caught before any of it is sent.
191+
Streamed responses are scanned across chunk boundaries, so a sensitive value that gets split between two writes is still detected. When the end of a chunk looks like the start of a sensitive value, that trailing text is held back briefly (up to 15ms, or until the next chunk arrives) so the full value can be caught before any of it is sent.
192192

193193
Our [framework integrations](/integrations/overview/) automatically apply the appropriate patches for your environment. For example, a Next.js integration will scan both server-rendered pages and API route responses.
194194

packages/varlock/src/runtime/patch-server-response.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@ import { debug } from './lib/debug';
1313
const patchedKey = '_patchedByVarlock';
1414

1515
/**
16-
* How long to hold back a possible partial secret before writing it out anyway. Chunks that
17-
* split a secret arrive back-to-back in practice, so this only fires when a response genuinely
18-
* pauses right after emitting something that looks like the start of a secret (e.g. SSE) -
19-
* without it, that trailing text would sit unsent until the next chunk or `end()`.
16+
* How long to hold back a possible partial secret before writing it out anyway. This is a
17+
* flush valve, not a detection window: chunks that split a secret arrive within the same tick
18+
* or a few ms of each other (renderer buffer splits, piped upstream fragments), so a short
19+
* timeout covers them while keeping the worst-case stall of a lookalike tail imperceptible.
20+
* It only fires when a response genuinely pauses right after emitting something that looks
21+
* like the start of a secret (e.g. an unframed SSE-style stream) - without it, that trailing
22+
* text would sit unsent until the next chunk or `end()`.
2023
*/
21-
const PENDING_FLUSH_TIMEOUT_MS = 100;
24+
const PENDING_FLUSH_TIMEOUT_MS = 15;
2225

2326
/**
2427
* Returns a sync decompressor for a Content-Encoding value, or undefined if unsupported.

packages/varlock/src/runtime/test/patch-server-response.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,33 @@ describe('patched ServerResponse - sensitive values split across chunks', () =>
223223
expect(() => res.write(`<html>${head}`)).not.toThrow();
224224
expect(() => res.end('-but-not-really</html>')).not.toThrow();
225225
});
226+
227+
it('still detects the secret when its head was already flushed by the holdback timer', async () => {
228+
const res = makeRes();
229+
expect(() => res.write(`<html>leaked: ${head}`)).not.toThrow();
230+
// wait past the holdback window so the timer flushes the withheld head, which
231+
// moves it into `carry` - the completing chunk must still be caught there
232+
await new Promise((resolve) => {
233+
setTimeout(resolve, 60);
234+
});
235+
expect(() => res.write(`${tail}</html>`)).toThrow(/DETECTED LEAKED SENSITIVE CONFIG/);
236+
});
237+
238+
it('reports success and fires the callback when a whole chunk is withheld', async () => {
239+
const res = makeRes();
240+
// the entire chunk is a possible secret prefix, so nothing goes out yet - but the
241+
// caller must still see a normal successful write or streams piping into the
242+
// response would stall waiting on the callback
243+
let cbFired = false;
244+
const returned = res.write(head, () => {
245+
cbFired = true;
246+
});
247+
expect(returned).toBe(true);
248+
await new Promise((resolve) => {
249+
process.nextTick(resolve);
250+
});
251+
expect(cbFired).toBe(true);
252+
});
226253
});
227254

228255
/*
@@ -253,6 +280,11 @@ describe('patched ServerResponse - pass-through integrity', () => {
253280
res.write(buf.subarray(0, mid));
254281
res.write(buf.subarray(mid));
255282
res.end();
283+
} else if (req.url === '/withheld-then-bare-end') {
284+
// tail is withheld as a lookalike, and end() carries no chunk of its own -
285+
// the withheld text must still be flushed as the final chunk
286+
res.write(`<html>${SECRET.slice(0, 12)}`);
287+
res.end();
256288
} else if (req.url === '/slow-lookalike') {
257289
// pauses right after a partial match, so the held-back text must still be flushed
258290
res.write(`<html>${SECRET.slice(0, 12)}`);
@@ -289,6 +321,11 @@ describe('patched ServerResponse - pass-through integrity', () => {
289321
expect(body).toBe(`<html>${multibyte}</html>`);
290322
});
291323

324+
it('delivers withheld text when end() carries no chunk', async () => {
325+
const body = await (await fetch(`${baseUrl}/withheld-then-bare-end`)).text();
326+
expect(body).toBe(`<html>${SECRET.slice(0, 12)}`);
327+
});
328+
292329
it('flushes withheld text without waiting for the response to end', async () => {
293330
const resp = await fetch(`${baseUrl}/slow-lookalike`);
294331
const reader = resp.body!.getReader();

0 commit comments

Comments
 (0)