fix(gateway): enforce request body size limits

This commit is contained in:
William Valentin
2026-02-15 21:44:36 -08:00
parent 22959ea3aa
commit d93c1c9f8d
13 changed files with 270 additions and 22 deletions
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { EventEmitter } from 'events';
import type { IncomingMessage } from 'http';
import { readRequestBody, RequestBodyTooLargeError } from './httpBody.js';
class MockRequest extends EventEmitter {
destroyed = false;
destroy(): this {
this.destroyed = true;
return this;
}
}
function asIncoming(req: MockRequest): IncomingMessage {
return req as unknown as IncomingMessage;
}
describe('readRequestBody', () => {
it('reads body under size limit', async () => {
const req = new MockRequest();
const bodyPromise = readRequestBody(asIncoming(req), { maxBytes: 1024 });
req.emit('data', Buffer.from('hello'));
req.emit('data', Buffer.from(' world'));
req.emit('end');
await expect(bodyPromise).resolves.toBe('hello world');
expect(req.destroyed).toBe(false);
});
it('rejects oversized body and destroys request', async () => {
const req = new MockRequest();
const bodyPromise = readRequestBody(asIncoming(req), { maxBytes: 5 });
req.emit('data', Buffer.from('12345'));
req.emit('data', Buffer.from('6'));
await expect(bodyPromise).rejects.toBeInstanceOf(RequestBodyTooLargeError);
expect(req.destroyed).toBe(true);
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { IncomingMessage } from 'http';
export interface ReadRequestBodyOptions {
maxBytes: number;
}
export class RequestBodyTooLargeError extends Error {
readonly maxBytes: number;
readonly receivedBytes: number;
constructor(maxBytes: number, receivedBytes: number) {
super(`Request body too large (${receivedBytes} bytes > ${maxBytes} bytes)`);
this.name = 'RequestBodyTooLargeError';
this.maxBytes = maxBytes;
this.receivedBytes = receivedBytes;
}
}
/** Read the full request body with an explicit max-size limit. */
export function readRequestBody(req: IncomingMessage, opts: ReadRequestBodyOptions): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalBytes = 0;
let settled = false;
const cleanup = () => {
req.off('data', onData);
req.off('end', onEnd);
req.off('error', onError);
};
const fail = (err: Error) => {
if (settled) {return;}
settled = true;
cleanup();
reject(err);
};
const onData = (chunk: Buffer | string) => {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += buf.length;
if (totalBytes > opts.maxBytes) {
if (typeof req.destroy === 'function') {
req.destroy();
}
fail(new RequestBodyTooLargeError(opts.maxBytes, totalBytes));
return;
}
chunks.push(buf);
};
const onEnd = () => {
if (settled) {return;}
settled = true;
cleanup();
resolve(Buffer.concat(chunks).toString('utf-8'));
};
const onError = (err: Error) => fail(err);
req.on('data', onData);
req.on('end', onEnd);
req.on('error', onError);
});
}