diff --git a/app/api/webhooks/moonpay/route.ts b/app/api/webhooks/moonpay/route.ts index d020e5e..fc5f5b8 100644 --- a/app/api/webhooks/moonpay/route.ts +++ b/app/api/webhooks/moonpay/route.ts @@ -1,11 +1,42 @@ +import crypto from 'crypto' import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' import { sendPaymentReceivedEmail } from '@/lib/email' import { logger } from '@/lib/logger' +function verifyMoonPaySignature(rawBody: string, signature: string, secret: string): boolean { + if (!signature || !secret) return false + const expected = crypto + .createHmac('sha256', secret) + .update(rawBody) + .digest('base64') + try { + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) + } catch { + return false // handles length mismatch + } +} + export async function POST(request: NextRequest) { try { - const event = await request.json() + // Step 1: Read raw body and verify webhook signature + const rawBody = await request.text() + const signature = request.headers.get('moonpay-signature') ?? '' + const secret = process.env.MOONPAY_WEBHOOK_KEY ?? '' + + if (!verifyMoonPaySignature(rawBody, signature, secret)) { + console.warn('MoonPay webhook: invalid signature') + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) + } + + // Step 2: Parse the verified body + let event: any + try { + event = JSON.parse(rawBody) + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + logger.info({ eventType: event.type }, 'MoonPay webhook') if ( diff --git a/scripts/verify-moonpay-webhook.ts b/scripts/verify-moonpay-webhook.ts new file mode 100644 index 0000000..edcaef0 --- /dev/null +++ b/scripts/verify-moonpay-webhook.ts @@ -0,0 +1,80 @@ +/** + * Verification script for MoonPay webhook signature. + * Run: npx tsx scripts/verify-moonpay-webhook.ts + * + * Tests: + * 1. Valid signature + valid body → passes + * 2. Valid signature + tampered body → rejected + * 3. Wrong secret → rejected + * 4. Missing/empty signature → rejected + * 5. Missing/empty secret → rejected + */ + +import crypto from 'crypto' + +function verifyMoonPaySignature(rawBody: string, signature: string, secret: string): boolean { + if (!signature || !secret) return false + const expected = crypto + .createHmac('sha256', secret) + .update(rawBody) + .digest('base64') + try { + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) + } catch { + return false // handles length mismatch + } +} + +const testSecret = 'wk_test_secret_123' +const payload = JSON.stringify({ + type: 'transaction_completed', + data: { status: 'completed', externalTransactionId: 'INV-2025-001' }, +}) +const validSignature = crypto + .createHmac('sha256', testSecret) + .update(payload) + .digest('base64') + +type TestCase = { description: string; result: boolean; expected: boolean } + +const tests: TestCase[] = [ + { + description: 'Valid signature + valid body', + result: verifyMoonPaySignature(payload, validSignature, testSecret), + expected: true, + }, + { + description: 'Valid signature + tampered body', + result: verifyMoonPaySignature(payload + 'x', validSignature, testSecret), + expected: false, + }, + { + description: 'Correct body + wrong secret', + result: verifyMoonPaySignature(payload, validSignature, 'wrong_secret'), + expected: false, + }, + { + description: 'Correct body + empty signature', + result: verifyMoonPaySignature(payload, '', testSecret), + expected: false, + }, + { + description: 'Correct body + valid signature + empty secret', + result: verifyMoonPaySignature(payload, validSignature, ''), + expected: false, + }, +] + +let passed = 0 +for (const test of tests) { + const ok = test.result === test.expected + if (ok) { + console.log(`✅ PASS: ${test.description}`) + passed++ + } else { + console.log(`❌ FAIL: ${test.description}`) + } +} + +console.log(`\n${passed}/${tests.length} tests passed`) +process.exit(passed === tests.length ? 0 : 1)