Skip to content

Commit 6e38bde

Browse files
committed
fix response scanner chunk boundaries
1 parent f52d278 commit 6e38bde

3 files changed

Lines changed: 72 additions & 18 deletions

File tree

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

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ type ScanState = {
6161
* (created lazily - responses that never hit the binary path don't need one) */
6262
decoder: TextDecoder | undefined,
6363
zlibChunks: Array<Buffer>,
64-
/** length of the decompressed output already scanned, so each chunk only rescans the new tail */
64+
/** streaming decoder for decompressed deltas, which may end inside a multi-byte character */
65+
decompressedDecoder: TextDecoder | undefined,
66+
/** byte length of the decompressed output already scanned, so each chunk only scans new bytes */
6567
decompressedLength: number,
6668
flushTimer: ReturnType<typeof setTimeout> | undefined,
6769
};
@@ -72,6 +74,7 @@ function getScanState(res: any): ScanState {
7274
carry: '',
7375
decoder: undefined,
7476
zlibChunks: [],
77+
decompressedDecoder: undefined,
7578
decompressedLength: 0,
7679
flushTimer: undefined,
7780
} satisfies ScanState;
@@ -87,6 +90,13 @@ function decodeChunk(state: ScanState, chunk?: Uint8Array, isFinal?: boolean) {
8790
return state.decoder.decode(chunk, { stream: !isFinal });
8891
}
8992

93+
function decodeDecompressedDelta(state: ScanState, decompressed: Buffer, isFinal = false) {
94+
state.decompressedDecoder ||= new TextDecoder();
95+
const delta = decompressed.subarray(state.decompressedLength);
96+
state.decompressedLength = decompressed.byteLength;
97+
return state.decompressedDecoder.decode(delta, { stream: !isFinal });
98+
}
99+
90100
function clearPendingFlush(state: ScanState) {
91101
if (state.flushTimer !== undefined) {
92102
clearTimeout(state.flushTimer);
@@ -201,6 +211,12 @@ export function patchGlobalServerResponse(opts?: {
201211
const state = getScanState(this);
202212
clearPendingFlush(state);
203213

214+
// A later chunk may be redacted to a different length. Once write() sends the
215+
// headers, Content-Length cannot be corrected, so use chunked framing instead.
216+
if (opts?.redactInsteadOfThrow && !this.headersSent && this.getHeader('content-length') !== undefined) {
217+
this.removeHeader('content-length');
218+
}
219+
204220
// have to deal with compressed data, which is awkward but possible
205221
const compressionType = this.getHeader('Content-Encoding');
206222
let chunkStr;
@@ -223,9 +239,7 @@ export function patchGlobalServerResponse(opts?: {
223239
// partial stream fails to decode here and gets scanned once more chunks arrive.
224240
try {
225241
const decompressedChunk = decompress(Buffer.concat(state.zlibChunks));
226-
const fullDecompressedData = decompressedChunk.toString('utf-8');
227-
chunkStr = fullDecompressedData.substring(state.decompressedLength);
228-
state.decompressedLength = fullDecompressedData.length;
242+
chunkStr = decodeDecompressedDelta(state, decompressedChunk);
229243
} catch (err) {
230244
// partial compressed data that doesn't decode yet — scanned when more chunks arrive
231245
}
@@ -296,18 +310,18 @@ export function patchGlobalServerResponse(opts?: {
296310

297311
if (isBinaryChunk && compressionType) {
298312
const decompress = getDecompressor(String(compressionType).toLowerCase());
299-
let decompressed: string | undefined;
313+
let decompressed: Buffer | undefined;
300314
if (decompress) {
301315
state.zlibChunks.push(endChunk as Buffer);
302316
try {
303-
decompressed = decompress(Buffer.concat(state.zlibChunks)).toString('utf-8');
317+
decompressed = decompress(Buffer.concat(state.zlibChunks));
304318
} catch (err) {
305319
// stream didn't decode, nothing more we can do at this point
306320
}
307321
}
308322
if (decompressed !== undefined) {
309323
// compressed output can't be scrubbed, so a detected leak always throws (see write above)
310-
scanForLeaks(state.carry + decompressed.substring(state.decompressedLength), {
324+
scanForLeaks(state.carry + decodeDecompressedDelta(state, decompressed, true), {
311325
method: 'patched ServerResponse.end',
312326
file: (this as any).req?.url,
313327
});

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ beforeAll(async () => {
6464
const body = `<html>leak: ${SECRET}</html>`;
6565
res.setHeader('content-length', Buffer.byteLength(body));
6666
res.end(body);
67+
} else if (req.url === '/content-length-split-leak') {
68+
const body = `<html>leak: ${SECRET}</html>`;
69+
res.setHeader('content-length', Buffer.byteLength(body));
70+
res.write(`<html>leak: ${SECRET.slice(0, 10)}`);
71+
res.end(`${SECRET.slice(10)}</html>`);
6772
} else if (req.url === '/partial-lookalike') {
6873
// ends mid-lookalike but never completes the secret - must arrive intact
6974
res.write(`<html>${SECRET.slice(0, 12)}`);
@@ -133,6 +138,15 @@ describe('patchGlobalServerResponse with redactInsteadOfThrow', () => {
133138
expect(resp.headers.get('content-length')).toBe(String(Buffer.byteLength(body)));
134139
});
135140

141+
it('switches from Content-Length when a split response may be redacted', async () => {
142+
const resp = await fetch(`${baseUrl}/content-length-split-leak`);
143+
const body = await resp.text();
144+
expect(body).not.toContain(SECRET);
145+
expect(body).toContain('▒');
146+
expect(resp.headers.get('content-length')).toBeNull();
147+
expect(resp.headers.get('transfer-encoding')).toBe('chunked');
148+
});
149+
136150
it('leaves text that only looks like the start of a secret intact', async () => {
137151
const body = await (await fetch(`${baseUrl}/partial-lookalike`)).text();
138152
expect(body).toBe(`<html>${SECRET.slice(0, 12)}-not-a-secret</html>`);

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

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ import { patchGlobalServerResponse } from '../patch-server-response';
1717
import { resetRedactionMap } from '../env';
1818

1919
const SECRET = 'super-secret-value-abc123';
20+
const UNICODE_SECRET = 'super-sëcret-value-abc123';
2021

2122
const FAKE_GRAPH = {
2223
sources: [],
2324
settings: {},
2425
config: {
2526
SECRET_KEY: { value: SECRET, isSensitive: true },
27+
UNICODE_SECRET_KEY: { value: UNICODE_SECRET, isSensitive: true },
2628
PUBLIC_KEY: { value: 'public-value', isSensitive: false },
2729
},
2830
} as any;
@@ -41,6 +43,21 @@ function makeRes(headers: Record<string, string> = {}) {
4143
return res;
4244
}
4345

46+
async function gzipInTwoFlushes(input: Buffer, splitAt: number) {
47+
const gz = zlib.createGzip();
48+
const compressed: Array<Buffer> = [];
49+
gz.on('data', (chunk) => compressed.push(chunk));
50+
const first = await new Promise<Buffer>((resolve) => {
51+
gz.write(input.subarray(0, splitAt));
52+
gz.flush(zlib.constants.Z_SYNC_FLUSH, () => resolve(Buffer.concat(compressed.splice(0))));
53+
});
54+
const second = await new Promise<Buffer>((resolve) => {
55+
gz.on('end', () => resolve(Buffer.concat(compressed)));
56+
gz.end(input.subarray(splitAt));
57+
});
58+
return [first, second] as const;
59+
}
60+
4461
beforeAll(() => {
4562
resetRedactionMap(FAKE_GRAPH);
4663
patchGlobalServerResponse();
@@ -172,26 +189,35 @@ describe('patched ServerResponse - sensitive values split across chunks', () =>
172189
});
173190

174191
it('detects a secret split across gzip flush boundaries', async () => {
175-
const gz = zlib.createGzip();
176-
const compressed: Array<Buffer> = [];
177-
gz.on('data', (c) => compressed.push(c));
192+
const input = Buffer.from(`<html>leaked: ${SECRET}</html>`);
193+
const splitAt = Buffer.byteLength(`<html>leaked: ${head}`);
178194
// Z_SYNC_FLUSH ends the first block on a byte boundary, which is how a server
179195
// streaming a compressed response emits a chunk mid-body
180-
const first = await new Promise<Buffer>((resolve) => {
181-
gz.write(`<html>leaked: ${head}`);
182-
gz.flush(zlib.constants.Z_SYNC_FLUSH, () => resolve(Buffer.concat(compressed.splice(0))));
183-
});
184-
const second = await new Promise<Buffer>((resolve) => {
185-
gz.on('end', () => resolve(Buffer.concat(compressed)));
186-
gz.end(`${tail}</html>`);
187-
});
196+
const [first, second] = await gzipInTwoFlushes(input, splitAt);
188197

189198
const res = makeRes({ 'content-encoding': 'gzip' });
190199
// each half decompresses on its own without ever containing the whole secret
191200
expect(() => res.write(first)).not.toThrow();
192201
expect(() => res.write(second)).toThrow(/DETECTED LEAKED SENSITIVE CONFIG/);
193202
});
194203

204+
it.each(['write', 'end'] as const)(
205+
'detects a Unicode secret when a compressed delta ends mid-character before %s()',
206+
async (completionMethod) => {
207+
const input = Buffer.from(`<html>leaked: ${UNICODE_SECRET}</html>`);
208+
const splitAt = input.indexOf(Buffer.from('ë')) + 1;
209+
const [first, second] = await gzipInTwoFlushes(input, splitAt);
210+
const firstDecompressed = zlib.unzipSync(first, {
211+
finishFlush: zlib.constants.Z_SYNC_FLUSH,
212+
});
213+
expect(firstDecompressed.subarray(-1)).toEqual(Buffer.from('ë').subarray(0, 1));
214+
215+
const res = makeRes({ 'content-encoding': 'gzip' });
216+
expect(() => res.write(first)).not.toThrow();
217+
expect(() => res[completionMethod](second)).toThrow(/DETECTED LEAKED SENSITIVE CONFIG/);
218+
},
219+
);
220+
195221
it('does not false-positive on text that merely starts like a secret', () => {
196222
const res = makeRes();
197223
expect(() => res.write(`<html>${head}`)).not.toThrow();

0 commit comments

Comments
 (0)