From d93c1c9f8df1158b281f39d48fe8a5ce38bf05cb Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 15 Feb 2026 21:44:36 -0800 Subject: [PATCH] fix(gateway): enforce request body size limits --- README.md | 9 +++++ config/default.yaml | 8 ++++ docs/deployment/PRODUCTION.md | 1 + src/automation/webhooks.test.ts | 18 +++++++++ src/automation/webhooks.ts | 31 ++++++++++------ src/config/schema.test.ts | 20 ++++++++++ src/config/schema.ts | 2 + src/daemon/channels.ts | 7 +++- src/daemon/services.ts | 1 + src/gateway/server.test.ts | 65 +++++++++++++++++++++++++++++++++ src/gateway/server.ts | 23 +++++++----- src/utils/httpBody.test.ts | 42 +++++++++++++++++++++ src/utils/httpBody.ts | 65 +++++++++++++++++++++++++++++++++ 13 files changed, 270 insertions(+), 22 deletions(-) create mode 100644 src/utils/httpBody.test.ts create mode 100644 src/utils/httpBody.ts diff --git a/README.md b/README.md index 1c976b9..6c66416 100644 --- a/README.md +++ b/README.md @@ -686,6 +686,15 @@ server: The web UI detects the locked state and disables auto-reconnect when rejected. +## Gateway Request Body Limit + +Cap inbound HTTP POST body size (webhooks and Gmail push) to reduce memory-DoS risk. + +```yaml +server: + max_request_body_bytes: 1048576 # 1 MiB +``` + ## Tailscale Serve Automatically expose the gateway via Tailscale Serve when the daemon starts. Requires Tailscale to be installed and authenticated on the host. diff --git a/config/default.yaml b/config/default.yaml index 4273c12..cec82e4 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -16,6 +16,8 @@ server: serve: false localhost: true port: 18800 + # Maximum inbound HTTP request body size (bytes) for webhooks/Gmail push. + max_request_body_bytes: 1048576 models: # ── Model tiers ──────────────────────────────────────────────────── @@ -45,6 +47,12 @@ models: # auth_mode: auto # auto | api_key | oauth (provider-specific) # use_oauth: false # compat alias for auth_mode: oauth # supports_audio: false # Override native audio detection per tier + fast: + provider: anthropic + model: claude-haiku-4-5-20251001 + complex: + provider: anthropic + model: claude-opus-4-6-20250715 local: provider: ollama model: glm-4.7-flash diff --git a/docs/deployment/PRODUCTION.md b/docs/deployment/PRODUCTION.md index b26974c..749d56e 100644 --- a/docs/deployment/PRODUCTION.md +++ b/docs/deployment/PRODUCTION.md @@ -248,6 +248,7 @@ server: tailscale_identity: true auth_http: true lock: false + max_request_body_bytes: 1048576 ``` Generate a secure token: diff --git a/src/automation/webhooks.test.ts b/src/automation/webhooks.test.ts index c9165b9..70a7975 100644 --- a/src/automation/webhooks.test.ts +++ b/src/automation/webhooks.test.ts @@ -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), diff --git a/src/automation/webhooks.ts b/src/automation/webhooks.ts index 4a52762..d8bfcf3 100644 --- a/src/automation/webhooks.ts +++ b/src/automation/webhooks.ts @@ -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 { - 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 }; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 52df363..c5cbfe8 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index fc30a5b..f68a7a8 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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. */ diff --git a/src/daemon/channels.ts b/src/daemon/channels.ts index 1cd1e98..7488e8e 100644 --- a/src/daemon/channels.ts +++ b/src/daemon/channels.ts @@ -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)`); diff --git a/src/daemon/services.ts b/src/daemon/services.ts index d060e3a..58875e5 100644 --- a/src/daemon/services.ts +++ b/src/daemon/services.ts @@ -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, diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 9b9b1e6..332e756 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -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(); + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 215223d..66dff05 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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 { - 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 }); } } diff --git a/src/utils/httpBody.test.ts b/src/utils/httpBody.test.ts new file mode 100644 index 0000000..98d1dfc --- /dev/null +++ b/src/utils/httpBody.test.ts @@ -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); + }); +}); diff --git a/src/utils/httpBody.ts b/src/utils/httpBody.ts new file mode 100644 index 0000000..36a1ce3 --- /dev/null +++ b/src/utils/httpBody.ts @@ -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 { + 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); + }); +}