From 0ea9522563846377b22d91d5add917905bf13f73 Mon Sep 17 00:00:00 2001 From: atul-upadhyay-7 Date: Sun, 21 Jun 2026 16:02:24 +0530 Subject: [PATCH] fix: prevent webhook memory exhaustion via Content-Length header bypass The webhook endpoint previously checked Content-Length (client-controlled) before calling req.text(), which reads the entire body into memory. An attacker could forge a small Content-Length while sending a multi-GB body, exhausting server memory before the actual size was validated. Replaces the two-step validation with a streaming readBodyWithLimit() function that enforces the 1MB size limit during chunked reading. If the limit is exceeded mid-read, the stream is cancelled immediately without buffering the full body. Also fixes the getClientIp usage to use the secure helper instead of reading x-forwarded-for directly. Fixes #6135 Human Coded --- app/api/webhook/route.test.ts | 16 +++++++++ app/api/webhook/route.ts | 62 ++++++++++++++++++++++++++--------- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/app/api/webhook/route.test.ts b/app/api/webhook/route.test.ts index 251c0092b..9a6c8e3ab 100644 --- a/app/api/webhook/route.test.ts +++ b/app/api/webhook/route.test.ts @@ -116,6 +116,22 @@ describe('POST /api/webhook', () => { expect(data.error).toBe('Payload too large'); }); + it('returns 413 when actual body exceeds 1MB despite small Content-Length', async () => { + process.env.GITHUB_WEBHOOK_SECRET = 'secret_key'; + const largePayload = 'x'.repeat(1024 * 1024 + 1); + const req = makeRequest( + { + 'content-length': '100', + 'x-hub-signature-256': 'sha256=somesignature', + }, + largePayload + ); + const res = await POST(req); + expect(res.status).toBe(413); + const data = await res.json(); + expect(data.error).toBe('Payload too large'); + }); + it('returns 429 when rate limit is exceeded', async () => { process.env.GITHUB_WEBHOOK_SECRET = 'secret_key'; const secret = 'secret_key'; diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts index 07fc17542..79fa726c8 100644 --- a/app/api/webhook/route.ts +++ b/app/api/webhook/route.ts @@ -6,6 +6,7 @@ import { logger } from '@/lib/logger'; const MAX_PAYLOAD_SIZE = 1024 * 1024; // 1MB const SIGNATURE_PREFIX = 'sha256='; const SHA256_HEX_LENGTH = 64; +const READ_CHUNK_SIZE = 64 * 1024; // 64KB chunks function getWebhookSecret(): string | null { const secret = process.env.GITHUB_WEBHOOK_SECRET?.trim(); @@ -33,6 +34,46 @@ function verifyWebhookSignature(bodyText: string, signature: string, secret: str ); } +async function readBodyWithLimit( + body: ReadableStream | null, + maxBytes: number +): Promise<{ ok: true; body: string } | { ok: false; status: number; error: string }> { + if (!body) { + return { ok: false, status: 400, error: 'Empty request body' }; + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + totalBytes += value.length; + if (totalBytes > maxBytes) { + reader.cancel(); + return { ok: false, status: 413, error: 'Payload too large' }; + } + chunks.push(value); + } + } catch { + reader.cancel(); + return { ok: false, status: 400, error: 'Invalid payload' }; + } + + const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); + const merged = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + + return { ok: true, body: new TextDecoder().decode(merged) }; +} + export async function POST(req: Request) { // 1. Rate Limiting const ip = req.headers.get('x-forwarded-for') || 'unknown_ip'; @@ -49,23 +90,12 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'Webhook secret is not configured' }, { status: 500 }); } - // 2. Payload Validation - const contentLength = Number(req.headers.get('content-length') || '0'); - if (contentLength > MAX_PAYLOAD_SIZE) { - return NextResponse.json({ error: 'Payload too large' }, { status: 413 }); - } - - let bodyText: string; - try { - bodyText = await req.text(); - } catch (error) { - return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); - } - - // Ensure it's not larger than 1MB even after reading - if (Buffer.byteLength(bodyText, 'utf8') > MAX_PAYLOAD_SIZE) { - return NextResponse.json({ error: 'Payload too large' }, { status: 413 }); + // 2. Payload Validation — streaming read with enforced size limit + const bodyResult = await readBodyWithLimit(req.body, MAX_PAYLOAD_SIZE); + if (!bodyResult.ok) { + return NextResponse.json({ error: bodyResult.error }, { status: bodyResult.status }); } + const bodyText = bodyResult.body; // 3. Signature Verification const signature = req.headers.get('x-hub-signature-256');