Skip to content

Commit 9359442

Browse files
committed
add seeded fuzz over random response chunkings
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.
1 parent d629fee commit 9359442

3 files changed

Lines changed: 120 additions & 1 deletion

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
Deterministic fuzz helpers for the response scanner tests: given a seed, produce a
3+
reproducible random chunking of a text fixture so any failure can be replayed exactly.
4+
*/
5+
6+
/** small deterministic PRNG (LCG) - no Math.random, so failures are reproducible by seed */
7+
export function makeRand(seed: number) {
8+
let state = seed;
9+
return () => {
10+
state = (state * 1664525 + 1013904223) % 4294967296;
11+
return state / 4294967296;
12+
};
13+
}
14+
15+
/**
16+
* Splits `text` into randomized write() chunks mixing string and Buffer pieces. String
17+
* pieces always break at character boundaries; Buffer pieces may be split further at
18+
* arbitrary byte offsets (including mid-character), but only between adjacent Buffers,
19+
* matching what a real byte stream can produce.
20+
*/
21+
export function randomChunks(text: string, rand: () => number): Array<string | Buffer> {
22+
const chars = Array.from(text);
23+
const chunks: Array<string | Buffer> = [];
24+
let i = 0;
25+
while (i < chars.length) {
26+
const take = 1 + Math.floor(rand() * 8);
27+
const piece = chars.slice(i, i + take).join('');
28+
i += take;
29+
if (rand() < 0.5) {
30+
chunks.push(piece);
31+
} else {
32+
let buf = Buffer.from(piece);
33+
while (buf.length > 1 && rand() < 0.4) {
34+
const cut = 1 + Math.floor(rand() * (buf.length - 1));
35+
chunks.push(buf.subarray(0, cut));
36+
buf = buf.subarray(cut);
37+
}
38+
chunks.push(buf);
39+
}
40+
}
41+
return chunks;
42+
}
43+
44+
/** writes the chunks to a response-like object, ending via end(chunk) or write+bare end() */
45+
export function writeChunks(
46+
res: { write: (chunk: any) => any, end: (chunk?: any) => any },
47+
chunks: Array<string | Buffer>,
48+
rand: () => number,
49+
) {
50+
const last = chunks[chunks.length - 1];
51+
for (const chunk of chunks.slice(0, -1)) res.write(chunk);
52+
if (rand() < 0.5) {
53+
res.end(last);
54+
} else {
55+
res.write(last);
56+
res.end();
57+
}
58+
}

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
} from 'vitest';
1414

1515
import { patchGlobalServerResponse } from '../patch-server-response';
16-
import { resetRedactionMap } from '../env';
16+
import { redactSensitiveConfig, resetRedactionMap } from '../env';
17+
import { makeRand, randomChunks, writeChunks } from './fuzz-helpers';
1718

1819
const SECRET = 'redact-mode-secret-xyz789';
1920

@@ -27,6 +28,8 @@ const FAKE_GRAPH = {
2728

2829
const htmlClean = `<html><body>${'filler '.repeat(100)}no secrets here</body></html>`;
2930
const htmlWithSecret = `<html><body>leak: ${SECRET}</body></html>`;
31+
// multi-byte chars exercise the decoder-alignment paths alongside redaction
32+
const fuzzLeakText = `<html>héllo 🔐 leak: ${SECRET} more ünïcode text</html>`;
3033

3134
let server: http.Server;
3235
let baseUrl: string;
@@ -90,6 +93,10 @@ beforeAll(async () => {
9093
// kill the connection like a framework's error handling would
9194
res.destroy();
9295
}
96+
} else if (req.url?.startsWith('/fuzz-redact')) {
97+
const seed = Number(new URL(req.url, 'http://localhost').searchParams.get('seed'));
98+
const rand = makeRand(seed);
99+
writeChunks(res, randomChunks(fuzzLeakText, rand), rand);
93100
} else {
94101
res.end('not found');
95102
}
@@ -163,6 +170,15 @@ describe('patchGlobalServerResponse with redactInsteadOfThrow', () => {
163170
expect(body).not.toContain('�');
164171
});
165172

173+
it('fuzz: random chunkings redact the secret and leave everything else intact', async () => {
174+
const expected = redactSensitiveConfig(fuzzLeakText);
175+
expect(expected).not.toContain(SECRET);
176+
for (let seed = 1; seed <= 30; seed++) {
177+
const body = await (await fetch(`${baseUrl}/fuzz-redact?seed=${seed}`)).text();
178+
expect(body, `seed ${seed}`).toBe(expected);
179+
}
180+
});
181+
166182
it('leaves text that only looks like the start of a secret intact', async () => {
167183
const body = await (await fetch(`${baseUrl}/partial-lookalike`)).text();
168184
expect(body).toBe(`<html>${SECRET.slice(0, 12)}-not-a-secret</html>`);

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515

1616
import { patchGlobalServerResponse } from '../patch-server-response';
1717
import { resetRedactionMap } from '../env';
18+
import { makeRand, randomChunks, writeChunks } from './fuzz-helpers';
1819

1920
const SECRET = 'super-secret-value-abc123';
2021
const UNICODE_SECRET = 'super-sëcret-value-abc123';
@@ -265,6 +266,9 @@ describe('patched ServerResponse - pass-through integrity', () => {
265266

266267
const partial = `${SECRET.slice(0, 12)}-not-a-secret`;
267268
const multibyte = 'héllo wörld 🔐 ünïcode';
269+
// multi-byte chars exercise the decoder-alignment paths, the lookalike exercises holdback
270+
const fuzzCleanText = `<html>héllo wörld 🔐 ünïcode ${'filler '.repeat(20)}${SECRET.slice(0, 12)}-lookalike</html>`;
271+
const fuzzLeakText = `<html>héllo 🔐 leak: ${SECRET} more ünïcode text</html>`;
268272

269273
beforeAll(async () => {
270274
server = http.createServer(async (req, res) => {
@@ -303,6 +307,19 @@ describe('patched ServerResponse - pass-through integrity', () => {
303307
// the withheld text must still be flushed as the final chunk
304308
res.write(`<html>${SECRET.slice(0, 12)}`);
305309
res.end();
310+
} else if (req.url?.startsWith('/fuzz-clean')) {
311+
const seed = Number(new URL(req.url, 'http://localhost').searchParams.get('seed'));
312+
const rand = makeRand(seed);
313+
writeChunks(res, randomChunks(fuzzCleanText, rand), rand);
314+
} else if (req.url?.startsWith('/fuzz-leak')) {
315+
const seed = Number(new URL(req.url, 'http://localhost').searchParams.get('seed'));
316+
const rand = makeRand(seed);
317+
try {
318+
writeChunks(res, randomChunks(fuzzLeakText, rand), rand);
319+
} catch (err) {
320+
// leak detected mid-write - kill the response like a real server error path
321+
res.destroy();
322+
}
306323
} else if (req.url === '/slow-lookalike') {
307324
// pauses right after a partial match, so the held-back text must still be flushed
308325
res.write(`<html>${SECRET.slice(0, 12)}`);
@@ -360,6 +377,34 @@ describe('patched ServerResponse - pass-through integrity', () => {
360377
expect(body).toBe(`<html>${SECRET.slice(0, 12)}`);
361378
});
362379

380+
it('fuzz: random chunkings of a clean response arrive byte-for-byte intact', async () => {
381+
for (let seed = 1; seed <= 30; seed++) {
382+
const resp = await fetch(`${baseUrl}/fuzz-clean?seed=${seed}`);
383+
const bytes = Buffer.from(await resp.arrayBuffer());
384+
expect(bytes.toString('utf8'), `seed ${seed}`).toBe(fuzzCleanText);
385+
expect(bytes.equals(Buffer.from(fuzzCleanText)), `seed ${seed}`).toBe(true);
386+
}
387+
});
388+
389+
it('fuzz: random chunkings never deliver the secret (throw mode)', async () => {
390+
for (let seed = 1; seed <= 30; seed++) {
391+
let received = '';
392+
try {
393+
const resp = await fetch(`${baseUrl}/fuzz-leak?seed=${seed}`);
394+
const reader = resp.body!.getReader();
395+
const decoder = new TextDecoder();
396+
for (;;) {
397+
const { value, done } = await reader.read();
398+
if (done) break;
399+
received += decoder.decode(value, { stream: true });
400+
}
401+
} catch (err) {
402+
// connection killed mid-response - whatever arrived is in `received`
403+
}
404+
expect(received.includes(SECRET), `seed ${seed}`).toBe(false);
405+
}
406+
});
407+
363408
it('flushes withheld text without waiting for the response to end', async () => {
364409
const resp = await fetch(`${baseUrl}/slow-lookalike`);
365410
const reader = resp.body!.getReader();

0 commit comments

Comments
 (0)