fix(websocket): cap decompressed message payloads

Apply the configured payload limit while per-message deflate is producing output, including fragmented frames. Abort oversized expansion before it can allocate unbounded memory.
This commit is contained in:
DrSmoothl
2026-07-10 13:41:36 +08:00
parent 920f75718a
commit ffede67aed
2 changed files with 59 additions and 4 deletions
+28 -4
View File
@@ -135,11 +135,35 @@ export function compressRaw(data: Buffer): Buffer {
}
export function decompressRaw(data: Buffer, maxPayload?: number): Buffer {
const inflated = zlib.inflateRawSync(Buffer.concat([data, TRAILER]), {
finishFlush: zlib.constants.Z_SYNC_FLUSH,
});
let inflated: Buffer;
try {
inflated = zlib.inflateRawSync(Buffer.concat([data, TRAILER]), {
finishFlush: zlib.constants.Z_SYNC_FLUSH,
// A post-inflate length check is too late for a compression bomb: zlib
// has already allocated the expanded buffer by then. Node enforces this
// limit while producing output. maxOutputLength cannot be zero, so keep
// the allocation ceiling at one byte for the maxPayload=0 edge case and
// let the exact check below reject any non-empty message.
...(maxPayload === undefined
? {}
: { maxOutputLength: Math.max(1, maxPayload) }),
});
} catch (cause) {
const zlibError = cause as Error & { code?: string };
if (maxPayload !== undefined && zlibError.code === 'ERR_BUFFER_TOO_LARGE') {
const err = new Error(
`Message too large after inflate (maxPayload=${maxPayload})`,
{ cause },
) as Error & { code?: number };
err.code = 1009;
throw err;
}
throw cause;
}
if (maxPayload !== undefined && inflated.length > maxPayload) {
const err = new Error('Message too large after inflate') as Error & { code?: number };
const err = new Error(
`Message too large after inflate (maxPayload=${maxPayload})`,
) as Error & { code?: number };
err.code = 1009;
throw err;
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { compressRaw, decompressRaw } from '../src/extensions';
describe('permessage-deflate decompression limits', () => {
it('accepts output exactly at maxPayload', () => {
const source = Buffer.alloc(4096, 0x61);
const compressed = compressRaw(source);
expect(decompressRaw(compressed, source.byteLength)).toEqual(source);
});
it('stops a compression bomb during inflate and maps it to WebSocket 1009', () => {
const maxPayload = 4096;
const compressed = compressRaw(Buffer.alloc(1024 * 1024, 0x61));
expect(compressed.byteLength).toBeLessThan(maxPayload);
let thrown: unknown;
try {
decompressRaw(compressed, maxPayload);
} catch (error) {
thrown = error;
}
expect(thrown).toMatchObject({
code: 1009,
message: `Message too large after inflate (maxPayload=${maxPayload})`,
cause: { code: 'ERR_BUFFER_TOO_LARGE' },
});
});
});