test/connect.test.js
Rama master · réplica de lectura del repositorio original.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { gzipSync } from 'node:zlib';
import {
gunzip, tryGunzip, unwrapRequest,
wrapEnvelope, StreamingFrameParser, MAX_FRAME_SIZE,
} from '../src/connect.js';
// ─── CONN-1: decompression output must be bounded ─────────────────────────
//
// A hijacked / compromised upstream (or MITM'd US-proxy) can send a Connect-RPC
// frame whose gzip payload is small on the wire (passes the ≤16MB frame-length
// guard) but inflates to gigabytes. Without maxOutputLength, gunzipSync
// accumulates up to kMaxLength (~2GB) and on a memory-constrained host the
// OOM-killer can reap the single-process proxy before the error is caught.
// The fix caps every decompression path at MAX_FRAME_SIZE. These tests prove the
// cap trips on a high-ratio bomb using a tiny, sub-second input (a ~17MB run of
// zeros compresses to a few KB) rather than actually allocating anything huge.
describe('CONN-1: gzip decompression is bounded by MAX_FRAME_SIZE', () => {
// Build a gzip payload that decompresses to just over the cap.
function makeBomb() {
const raw = Buffer.alloc(MAX_FRAME_SIZE + 1024); // all zeros → compresses tiny
const compressed = gzipSync(raw);
assert.ok(compressed.length < MAX_FRAME_SIZE, 'bomb is small on the wire (passes length guard)');
return compressed;
}
it('StreamingFrameParser.drain rejects a high-ratio gzip frame instead of allocating GBs', () => {
const bomb = makeBomb();
// flags 0x01 (gzip), wire length = compressed size (well under 16MB).
const frame = Buffer.alloc(5 + bomb.length);
frame[0] = 0x01;
frame.writeUInt32BE(bomb.length, 1);
bomb.copy(frame, 5);
const parser = new StreamingFrameParser();
parser.push(frame);
assert.throws(() => parser.drain(), /decompression failed/i);
});
it('drain completes in well under a second (bounded, not a real 2GB alloc)', () => {
const bomb = makeBomb();
const frame = Buffer.alloc(5 + bomb.length);
frame[0] = 0x01;
frame.writeUInt32BE(bomb.length, 1);
bomb.copy(frame, 5);
const parser = new StreamingFrameParser();
parser.push(frame);
const t0 = Date.now();
assert.throws(() => parser.drain());
assert.ok(Date.now() - t0 < 1000, 'the cap trips quickly, no gigabyte accumulation');
});
it('gunzip() (shared helper) throws on a bomb rather than inflating unbounded', () => {
assert.throws(() => gunzip(makeBomb()), /Buffer/i);
});
it('tryGunzip() returns null on a bomb (never inflates unbounded)', () => {
assert.equal(tryGunzip(makeBomb()), null);
});
it('unwrapRequest caps an envelope-wrapped gzip bomb', () => {
const bomb = makeBomb();
const frame = Buffer.alloc(5 + bomb.length);
frame[0] = 0x01;
frame.writeUInt32BE(bomb.length, 1);
bomb.copy(frame, 5);
assert.throws(() => unwrapRequest(frame), /Buffer/i);
});
it('unwrapRequest caps an HTTP-level content-encoding: gzip bomb', () => {
assert.throws(
() => unwrapRequest(makeBomb(), { 'content-encoding': 'gzip' }),
/Buffer/i,
);
});
// Regression guard: a legitimately-sized gzip frame still round-trips.
it('a normal gzip frame under the cap still decodes correctly', () => {
const body = Buffer.from('the answer is 42');
const frame = wrapEnvelope(body, { compress: true });
const parser = new StreamingFrameParser();
parser.push(frame);
const frames = parser.drain();
assert.equal(frames.length, 1);
assert.equal(frames[0].payload.toString('utf8'), 'the answer is 42');
});
it('the frame-length guard still rejects an absurd advertised length', () => {
// A frame header claiming > 16MB payload must be rejected before any read.
const frame = Buffer.alloc(5);
frame[0] = 0x00;
frame.writeUInt32BE(MAX_FRAME_SIZE + 1, 1);
const parser = new StreamingFrameParser();
parser.push(frame);
assert.throws(() => parser.drain(), /frame size/i);
});
});