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
+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 });
}
}