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.
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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] },
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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