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
+18
View File
@@ -220,6 +220,24 @@ describe('WebhookHandler', () => {
expect(messages).toHaveLength(0);
});
it('rejects oversized payloads with 413', async () => {
const webhooks = [makeWebhook()];
handler = new WebhookHandler(webhooks, mockChannelRegistry as any, 'shared_session', 16);
const messages: InboundMessage[] = [];
handler.onMessage((msg: InboundMessage) => messages.push(msg));
await handler.connect();
const req = mockRequest('x'.repeat(64));
const res = mockResponse();
const result = await handler.handleRequest('test-hook', req, res);
expect(result).toBe(false);
expect(res.statusCode_).toBe(413);
expect(messages).toHaveLength(0);
});
it('forwards response to output channel on send()', async () => {
const mockOutputAdapter = {
send: vi.fn().mockResolvedValue(undefined),
+19 -12
View File
@@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'http';
import type { WebhookConfig } from '../config/schema.js';
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
import { auditLogger } from '../audit/index.js';
import { RequestBodyTooLargeError, readRequestBody } from '../utils/httpBody.js';
/** Minimal interface for the parts of ChannelRegistry we need. */
interface ChannelLookup {
@@ -11,16 +12,6 @@ interface ChannelLookup {
type DeliveryMode = 'shared_session' | 'isolated_job';
/** Read the full request body as a string. */
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}
/** Verify HMAC-SHA256 signature from the X-Webhook-Signature header. */
function verifyHmac(body: string, secret: string, signature: string): boolean {
const expected = createHmac('sha256', secret).update(body).digest('hex');
@@ -62,6 +53,7 @@ function renderTemplate(template: string, body: string): string {
}
export class WebhookHandler implements ChannelAdapter {
private static readonly DEFAULT_MAX_REQUEST_BODY_BYTES = 1_048_576; // 1 MiB
readonly name = 'webhook';
private _status: ChannelStatus = 'disconnected';
private messageHandler?: (msg: InboundMessage) => void;
@@ -71,6 +63,7 @@ export class WebhookHandler implements ChannelAdapter {
private readonly webhookConfigs: WebhookConfig[],
private readonly channelLookup: ChannelLookup,
private readonly deliveryMode: DeliveryMode = 'shared_session',
private readonly maxRequestBodyBytes: number = WebhookHandler.DEFAULT_MAX_REQUEST_BODY_BYTES,
) {
for (const webhook of webhookConfigs) {
this.webhooks.set(webhook.name, webhook);
@@ -137,7 +130,21 @@ export class WebhookHandler implements ChannelAdapter {
return false;
}
const body = await readBody(req);
let body = '';
try {
body = await readRequestBody(req, { maxBytes: this.maxRequestBodyBytes });
} catch (err) {
if (err instanceof RequestBodyTooLargeError) {
auditLogger?.webhookDenied(webhookName, `Payload too large (>${this.maxRequestBodyBytes} bytes)`);
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Payload too large' }));
return false;
}
auditLogger?.webhookDenied(webhookName, err instanceof Error ? err.message : 'Failed to read request body');
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid request body' }));
return false;
}
// Verify HMAC if secret is configured
const signatureVerified = !webhook.secret;
@@ -195,4 +202,4 @@ export class WebhookHandler implements ChannelAdapter {
}
// Export helpers for testing
export { readBody as _readBody, verifyHmac as _verifyHmac, renderTemplate as _renderTemplate };
export { verifyHmac as _verifyHmac, renderTemplate as _renderTemplate };