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 };
+20
View File
@@ -28,6 +28,26 @@ describe('configSchema — sandbox', () => {
});
});
describe('configSchema — server', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-3' } },
};
it('defaults max_request_body_bytes', () => {
const result = configSchema.parse(minimalConfig);
expect(result.server.max_request_body_bytes).toBe(1_048_576);
});
it('accepts custom max_request_body_bytes', () => {
const result = configSchema.parse({
...minimalConfig,
server: { max_request_body_bytes: 2048 },
});
expect(result.server.max_request_body_bytes).toBe(2048);
});
});
describe('configSchema — agent_configs', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
+2
View File
@@ -36,6 +36,8 @@ const serverSchema = z.object({
auth_http: z.boolean().default(true),
/** Single-client gateway lock. When true, only one WebSocket client can be connected at a time. */
lock: z.boolean().default(false),
/** Maximum size (bytes) for inbound HTTP request bodies (webhooks/Gmail push). */
max_request_body_bytes: z.number().min(1024).max(10 * 1024 * 1024).default(1_048_576),
});
/** All supported model provider identifiers. Used by the config schema and TUI autocompletion. */
+6 -1
View File
@@ -99,7 +99,12 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult {
// Register webhook handler adapter (if any webhooks configured)
let webhookHandler: WebhookHandler | undefined;
if (config.automation.webhooks.length > 0) {
webhookHandler = new WebhookHandler(config.automation.webhooks, channelRegistry, config.automation.delivery_mode);
webhookHandler = new WebhookHandler(
config.automation.webhooks,
channelRegistry,
config.automation.delivery_mode,
config.server.max_request_body_bytes,
);
channelRegistry.register(webhookHandler);
gateway.setWebhookHandler(webhookHandler);
console.log(`Registered ${config.automation.webhooks.length} webhook(s)`);
+1
View File
@@ -311,6 +311,7 @@ export function createGateway(deps: GatewayDeps): GatewayServer {
},
authHttp: config.server.auth_http,
lock: config.server.lock,
maxRequestBodyBytes: config.server.max_request_body_bytes,
commandRegistry: deps.commandRegistry,
intentRegistry: deps.intentRegistry,
routingPolicy: deps.routingPolicy,
+65
View File
@@ -438,3 +438,68 @@ describe('GatewayServer HTTP auth', () => {
expect(res.headers.get('content-type')).toBe('text/html');
});
});
describe('GatewayServer request body limits', () => {
const BODY_PORT = 18896;
let bodyLimitServer: GatewayServer;
const gmailHandler = {
handlePushNotification: vi.fn(async () => {}),
};
beforeAll(async () => {
if (!LISTEN_ALLOWED) {
return;
}
bodyLimitServer = new GatewayServer({
port: BODY_PORT,
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
modelClient: mockModelClient,
systemPrompt: 'Test prompt',
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
gmailHandler: gmailHandler as unknown as GatewayServerConfig['gmailHandler'],
maxRequestBodyBytes: 64,
uiDir: resolve(import.meta.dirname, 'ui'),
});
await bodyLimitServer.start();
});
afterAll(async () => {
if (!LISTEN_ALLOWED) {
return;
}
await bodyLimitServer.stop();
});
it('accepts gmail push body under limit', async () => {
if (!LISTEN_ALLOWED) {
return;
}
gmailHandler.handlePushNotification.mockClear();
const body = JSON.stringify({ message: { data: 'abc' } });
const res = await fetch(`http://127.0.0.1:${BODY_PORT}/gmail/push`, {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(200);
expect(gmailHandler.handlePushNotification).toHaveBeenCalledWith('abc');
});
it('rejects gmail push body over limit with 413', async () => {
if (!LISTEN_ALLOWED) {
return;
}
gmailHandler.handlePushNotification.mockClear();
const body = JSON.stringify({ message: { data: 'x'.repeat(2048) } });
const res = await fetch(`http://127.0.0.1:${BODY_PORT}/gmail/push`, {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(413);
expect(gmailHandler.handlePushNotification).not.toHaveBeenCalled();
});
});
+14 -9
View File
@@ -41,6 +41,7 @@ import type { CommandRegistry } from '../commands/index.js';
import type { ComponentRegistry } from '../intents/index.js';
import type { RoutingPolicy } from '../routing/index.js';
import type { ChannelRegistry } from '../channels/index.js';
import { RequestBodyTooLargeError, readRequestBody } from '../utils/httpBody.js';
export interface GatewayServerConfig {
port: number;
@@ -67,6 +68,8 @@ export interface GatewayServerConfig {
gmailHandler?: GmailWatcher;
/** Optional callback to retrieve per-session token usage data for the dashboard. */
getTokenUsage?: () => TokenUsageEntry[];
/** Maximum allowed request body size for inbound HTTP POST bodies. */
maxRequestBodyBytes?: number;
/** Optional pairing manager for DM pairing code management via gateway. */
pairingManager?: PairingManager;
memoryStore?: MemoryStore;
@@ -76,6 +79,7 @@ export interface GatewayServerConfig {
}
export class GatewayServer {
private static readonly DEFAULT_MAX_REQUEST_BODY_BYTES = 1_048_576; // 1 MiB
private wss: WebSocketServer | null = null;
private httpServer: HttpServer | null = null;
private router: Router;
@@ -348,9 +352,14 @@ export class GatewayServer {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
console.error('Gmail push handler error:', err instanceof Error ? err.message : err);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid request' }));
if (err instanceof RequestBodyTooLargeError) {
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Payload too large' }));
} else {
console.error('Gmail push handler error:', err instanceof Error ? err.message : err);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid request' }));
}
}
return;
}
@@ -444,11 +453,7 @@ export class GatewayServer {
/** Read the full request body as a string. */
private readRequestBody(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);
});
const maxBytes = this.config.maxRequestBodyBytes ?? GatewayServer.DEFAULT_MAX_REQUEST_BODY_BYTES;
return readRequestBody(req, { maxBytes });
}
}
+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);
});
}