Ctrl K
ComunidadGuías…

test/connect.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);
  });
});