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
+9
View File
@@ -686,6 +686,15 @@ server:
The web UI detects the locked state and disables auto-reconnect when rejected. 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 ## Tailscale Serve
Automatically expose the gateway via Tailscale Serve when the daemon starts. Requires Tailscale to be installed and authenticated on the host. Automatically expose the gateway via Tailscale Serve when the daemon starts. Requires Tailscale to be installed and authenticated on the host.
+8
View File
@@ -16,6 +16,8 @@ server:
serve: false serve: false
localhost: true localhost: true
port: 18800 port: 18800
# Maximum inbound HTTP request body size (bytes) for webhooks/Gmail push.
max_request_body_bytes: 1048576
models: models:
# ── Model tiers ──────────────────────────────────────────────────── # ── Model tiers ────────────────────────────────────────────────────
@@ -45,6 +47,12 @@ models:
# auth_mode: auto # auto | api_key | oauth (provider-specific) # auth_mode: auto # auto | api_key | oauth (provider-specific)
# use_oauth: false # compat alias for auth_mode: oauth # use_oauth: false # compat alias for auth_mode: oauth
# supports_audio: false # Override native audio detection per tier # 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: local:
provider: ollama provider: ollama
model: glm-4.7-flash model: glm-4.7-flash
+1
View File
@@ -248,6 +248,7 @@ server:
tailscale_identity: true tailscale_identity: true
auth_http: true auth_http: true
lock: false lock: false
max_request_body_bytes: 1048576
``` ```
Generate a secure token: Generate a secure token:
+18
View File
@@ -220,6 +220,24 @@ describe('WebhookHandler', () => {
expect(messages).toHaveLength(0); 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 () => { it('forwards response to output channel on send()', async () => {
const mockOutputAdapter = { const mockOutputAdapter = {
send: vi.fn().mockResolvedValue(undefined), 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 { WebhookConfig } from '../config/schema.js';
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js'; import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
import { auditLogger } from '../audit/index.js'; import { auditLogger } from '../audit/index.js';
import { RequestBodyTooLargeError, readRequestBody } from '../utils/httpBody.js';
/** Minimal interface for the parts of ChannelRegistry we need. */ /** Minimal interface for the parts of ChannelRegistry we need. */
interface ChannelLookup { interface ChannelLookup {
@@ -11,16 +12,6 @@ interface ChannelLookup {
type DeliveryMode = 'shared_session' | 'isolated_job'; 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. */ /** Verify HMAC-SHA256 signature from the X-Webhook-Signature header. */
function verifyHmac(body: string, secret: string, signature: string): boolean { function verifyHmac(body: string, secret: string, signature: string): boolean {
const expected = createHmac('sha256', secret).update(body).digest('hex'); 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 { export class WebhookHandler implements ChannelAdapter {
private static readonly DEFAULT_MAX_REQUEST_BODY_BYTES = 1_048_576; // 1 MiB
readonly name = 'webhook'; readonly name = 'webhook';
private _status: ChannelStatus = 'disconnected'; private _status: ChannelStatus = 'disconnected';
private messageHandler?: (msg: InboundMessage) => void; private messageHandler?: (msg: InboundMessage) => void;
@@ -71,6 +63,7 @@ export class WebhookHandler implements ChannelAdapter {
private readonly webhookConfigs: WebhookConfig[], private readonly webhookConfigs: WebhookConfig[],
private readonly channelLookup: ChannelLookup, private readonly channelLookup: ChannelLookup,
private readonly deliveryMode: DeliveryMode = 'shared_session', private readonly deliveryMode: DeliveryMode = 'shared_session',
private readonly maxRequestBodyBytes: number = WebhookHandler.DEFAULT_MAX_REQUEST_BODY_BYTES,
) { ) {
for (const webhook of webhookConfigs) { for (const webhook of webhookConfigs) {
this.webhooks.set(webhook.name, webhook); this.webhooks.set(webhook.name, webhook);
@@ -137,7 +130,21 @@ export class WebhookHandler implements ChannelAdapter {
return false; 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 // Verify HMAC if secret is configured
const signatureVerified = !webhook.secret; const signatureVerified = !webhook.secret;
@@ -195,4 +202,4 @@ export class WebhookHandler implements ChannelAdapter {
} }
// Export helpers for testing // 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', () => { describe('configSchema — agent_configs', () => {
const minimalConfig = { const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] }, 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), auth_http: z.boolean().default(true),
/** Single-client gateway lock. When true, only one WebSocket client can be connected at a time. */ /** Single-client gateway lock. When true, only one WebSocket client can be connected at a time. */
lock: z.boolean().default(false), 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. */ /** 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) // Register webhook handler adapter (if any webhooks configured)
let webhookHandler: WebhookHandler | undefined; let webhookHandler: WebhookHandler | undefined;
if (config.automation.webhooks.length > 0) { 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); channelRegistry.register(webhookHandler);
gateway.setWebhookHandler(webhookHandler); gateway.setWebhookHandler(webhookHandler);
console.log(`Registered ${config.automation.webhooks.length} webhook(s)`); 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, authHttp: config.server.auth_http,
lock: config.server.lock, lock: config.server.lock,
maxRequestBodyBytes: config.server.max_request_body_bytes,
commandRegistry: deps.commandRegistry, commandRegistry: deps.commandRegistry,
intentRegistry: deps.intentRegistry, intentRegistry: deps.intentRegistry,
routingPolicy: deps.routingPolicy, routingPolicy: deps.routingPolicy,
+65
View File
@@ -438,3 +438,68 @@ describe('GatewayServer HTTP auth', () => {
expect(res.headers.get('content-type')).toBe('text/html'); 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 { ComponentRegistry } from '../intents/index.js';
import type { RoutingPolicy } from '../routing/index.js'; import type { RoutingPolicy } from '../routing/index.js';
import type { ChannelRegistry } from '../channels/index.js'; import type { ChannelRegistry } from '../channels/index.js';
import { RequestBodyTooLargeError, readRequestBody } from '../utils/httpBody.js';
export interface GatewayServerConfig { export interface GatewayServerConfig {
port: number; port: number;
@@ -67,6 +68,8 @@ export interface GatewayServerConfig {
gmailHandler?: GmailWatcher; gmailHandler?: GmailWatcher;
/** Optional callback to retrieve per-session token usage data for the dashboard. */ /** Optional callback to retrieve per-session token usage data for the dashboard. */
getTokenUsage?: () => TokenUsageEntry[]; getTokenUsage?: () => TokenUsageEntry[];
/** Maximum allowed request body size for inbound HTTP POST bodies. */
maxRequestBodyBytes?: number;
/** Optional pairing manager for DM pairing code management via gateway. */ /** Optional pairing manager for DM pairing code management via gateway. */
pairingManager?: PairingManager; pairingManager?: PairingManager;
memoryStore?: MemoryStore; memoryStore?: MemoryStore;
@@ -76,6 +79,7 @@ export interface GatewayServerConfig {
} }
export class GatewayServer { export class GatewayServer {
private static readonly DEFAULT_MAX_REQUEST_BODY_BYTES = 1_048_576; // 1 MiB
private wss: WebSocketServer | null = null; private wss: WebSocketServer | null = null;
private httpServer: HttpServer | null = null; private httpServer: HttpServer | null = null;
private router: Router; private router: Router;
@@ -348,9 +352,14 @@ export class GatewayServer {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true })); res.end(JSON.stringify({ ok: true }));
} catch (err) { } catch (err) {
console.error('Gmail push handler error:', err instanceof Error ? err.message : err); if (err instanceof RequestBodyTooLargeError) {
res.writeHead(400, { 'Content-Type': 'application/json' }); res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid request' })); 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; return;
} }
@@ -444,11 +453,7 @@ export class GatewayServer {
/** Read the full request body as a string. */ /** Read the full request body as a string. */
private readRequestBody(req: IncomingMessage): Promise<string> { private readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => { const maxBytes = this.config.maxRequestBodyBytes ?? GatewayServer.DEFAULT_MAX_REQUEST_BODY_BYTES;
const chunks: Buffer[] = []; return readRequestBody(req, { maxBytes });
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
} }
} }
+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);
});
}