Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/api/webhook/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
62 changes: 46 additions & 16 deletions app/api/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -33,6 +34,46 @@ function verifyWebhookSignature(bodyText: string, signature: string, secret: str
);
}

async function readBodyWithLimit(
body: ReadableStream<Uint8Array> | 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';
Expand All @@ -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');
Expand Down
Loading