fix(gateway): enforce request body size limits
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user