fix(gateway): enforce request body size limits
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -248,6 +248,7 @@ server:
|
||||
tailscale_identity: true
|
||||
auth_http: true
|
||||
lock: false
|
||||
max_request_body_bytes: 1048576
|
||||
```
|
||||
|
||||
Generate a secure token:
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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] },
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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