diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts new file mode 100644 index 000000000..5cd97819b --- /dev/null +++ b/app/api/webhook/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from 'next/server'; +import crypto from 'crypto'; + +const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET || 'development_secret'; +const MAX_PAYLOAD_SIZE = 1024 * 1024; // 1MB + +// In-memory rate limiting map: ip -> { count, resetTime } +const rateLimitMap = new Map(); +const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute +const MAX_REQUESTS_PER_WINDOW = 10; + +function checkRateLimit(ip: string): boolean { + const now = Date.now(); + let record = rateLimitMap.get(ip); + if (!record || now > record.resetTime) { + record = { count: 1, resetTime: now + RATE_LIMIT_WINDOW }; + rateLimitMap.set(ip, record); + return true; + } + record.count++; + if (record.count > MAX_REQUESTS_PER_WINDOW) { + return false; + } + return true; +} + +export async function POST(req: Request) { + // 1. Rate Limiting + const ip = req.headers.get('x-forwarded-for') || 'unknown_ip'; + if (!checkRateLimit(ip)) { + return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); + } + + // 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 }); + } + + // 3. Signature Verification + const signature = req.headers.get('x-hub-signature-256'); + if (!signature) { + return NextResponse.json({ error: 'Missing signature' }, { status: 401 }); + } + + const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET); + const digest = 'sha256=' + hmac.update(bodyText).digest('hex'); + + if (signature !== digest) { + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); + } + + // Valid payload, proceed... + let payload; + try { + payload = JSON.parse(bodyText); + } catch (error) { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + // Handle payload... + return NextResponse.json( + { success: true, message: 'Webhook received securely' }, + { status: 200 } + ); +}