- Webhook HMAC: added consumer verification JSDoc with Node.js example using crypto.timingSafeEqual - CSRF rate limiting: 20 requests/15min per IP on NextAuth /csrf endpoint - Renamed withRateLimit to withPostRateLimit/withGetRateLimit for clarity - 429 responses include Retry-After header Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
215 lines
6.0 KiB
TypeScript
215 lines
6.0 KiB
TypeScript
import crypto from 'crypto'
|
|
import { Prisma } from '@prisma/client'
|
|
import { prisma } from '@/lib/prisma'
|
|
|
|
/**
|
|
* MOPC Webhook Signature Verification
|
|
* ====================================
|
|
*
|
|
* Every outbound webhook delivery is signed with HMAC-SHA256 using the
|
|
* webhook's shared secret. The signature is sent in the `X-Webhook-Signature`
|
|
* header with a `sha256=` prefix.
|
|
*
|
|
* Additional headers included with each delivery:
|
|
* - X-Webhook-Event: the event type (e.g. "evaluation.submitted")
|
|
* - X-Webhook-Delivery: unique delivery ID (UUID)
|
|
*
|
|
* To verify a delivery on the consumer side:
|
|
*
|
|
* // Node.js example
|
|
* const crypto = require('crypto');
|
|
*
|
|
* function verifySignature(secret, body, signatureHeader) {
|
|
* const expected = 'sha256=' + crypto
|
|
* .createHmac('sha256', secret)
|
|
* .update(body, 'utf8') // raw request body string
|
|
* .digest('hex');
|
|
* return crypto.timingSafeEqual(
|
|
* Buffer.from(expected),
|
|
* Buffer.from(signatureHeader),
|
|
* );
|
|
* }
|
|
*
|
|
* // In your handler:
|
|
* const sig = req.headers['x-webhook-signature'];
|
|
* if (!verifySignature(WEBHOOK_SECRET, rawBody, sig)) {
|
|
* return res.status(401).send('Invalid signature');
|
|
* }
|
|
*
|
|
* IMPORTANT:
|
|
* - Always verify against the raw request body (before JSON parsing).
|
|
* - Use timing-safe comparison to prevent timing attacks.
|
|
* - The secret can be regenerated via the admin UI (Settings → Webhooks).
|
|
*/
|
|
|
|
/**
|
|
* Dispatch a webhook event to all active webhooks subscribed to this event.
|
|
*/
|
|
export async function dispatchWebhookEvent(
|
|
event: string,
|
|
payload: Record<string, unknown>
|
|
): Promise<number> {
|
|
const webhooks = await prisma.webhook.findMany({
|
|
where: {
|
|
isActive: true,
|
|
events: { has: event },
|
|
},
|
|
})
|
|
|
|
if (webhooks.length === 0) return 0
|
|
|
|
let deliveryCount = 0
|
|
|
|
for (const webhook of webhooks) {
|
|
try {
|
|
const delivery = await prisma.webhookDelivery.create({
|
|
data: {
|
|
webhookId: webhook.id,
|
|
event,
|
|
payload: payload as Prisma.InputJsonValue,
|
|
status: 'PENDING',
|
|
attempts: 0,
|
|
},
|
|
})
|
|
|
|
// Attempt delivery asynchronously (don't block the caller)
|
|
deliverWebhook(delivery.id).catch((err) => {
|
|
console.error(`[Webhook] Background delivery failed for ${delivery.id}:`, err)
|
|
})
|
|
|
|
deliveryCount++
|
|
} catch (error) {
|
|
console.error(`[Webhook] Failed to create delivery for webhook ${webhook.id}:`, error)
|
|
}
|
|
}
|
|
|
|
return deliveryCount
|
|
}
|
|
|
|
/**
|
|
* Attempt to deliver a single webhook.
|
|
*/
|
|
export async function deliverWebhook(deliveryId: string): Promise<void> {
|
|
const delivery = await prisma.webhookDelivery.findUnique({
|
|
where: { id: deliveryId },
|
|
include: { webhook: true },
|
|
})
|
|
|
|
if (!delivery || !delivery.webhook) {
|
|
console.error(`[Webhook] Delivery ${deliveryId} not found`)
|
|
return
|
|
}
|
|
|
|
const { webhook } = delivery
|
|
const payloadStr = JSON.stringify(delivery.payload)
|
|
|
|
// Sign payload with HMAC-SHA256
|
|
const signature = crypto
|
|
.createHmac('sha256', webhook.secret)
|
|
.update(payloadStr)
|
|
.digest('hex')
|
|
|
|
// Build headers
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'X-Webhook-Signature': `sha256=${signature}`,
|
|
'X-Webhook-Event': delivery.event,
|
|
'X-Webhook-Delivery': delivery.id,
|
|
}
|
|
|
|
// Merge custom headers from webhook config
|
|
if (webhook.headers && typeof webhook.headers === 'object') {
|
|
const customHeaders = webhook.headers as Record<string, string>
|
|
for (const [key, value] of Object.entries(customHeaders)) {
|
|
if (typeof value === 'string') {
|
|
headers[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), 30000) // 30s timeout
|
|
|
|
const response = await fetch(webhook.url, {
|
|
method: 'POST',
|
|
headers,
|
|
body: payloadStr,
|
|
signal: controller.signal,
|
|
})
|
|
|
|
clearTimeout(timeout)
|
|
|
|
const responseBody = await response.text().catch(() => '')
|
|
|
|
await prisma.webhookDelivery.update({
|
|
where: { id: deliveryId },
|
|
data: {
|
|
status: response.ok ? 'DELIVERED' : 'FAILED',
|
|
responseStatus: response.status,
|
|
responseBody: responseBody.slice(0, 4000), // Truncate long responses
|
|
attempts: delivery.attempts + 1,
|
|
lastAttemptAt: new Date(),
|
|
},
|
|
})
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
|
|
|
await prisma.webhookDelivery.update({
|
|
where: { id: deliveryId },
|
|
data: {
|
|
status: 'FAILED',
|
|
responseBody: errorMessage.slice(0, 4000),
|
|
attempts: delivery.attempts + 1,
|
|
lastAttemptAt: new Date(),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retry all failed webhook deliveries that haven't exceeded max retries.
|
|
* Called by cron.
|
|
*/
|
|
export async function retryFailedDeliveries(): Promise<{
|
|
retried: number
|
|
errors: number
|
|
}> {
|
|
let retried = 0
|
|
let errors = 0
|
|
|
|
const failedDeliveries = await prisma.webhookDelivery.findMany({
|
|
where: {
|
|
status: 'FAILED',
|
|
},
|
|
include: {
|
|
webhook: {
|
|
select: { maxRetries: true, isActive: true },
|
|
},
|
|
},
|
|
})
|
|
|
|
for (const delivery of failedDeliveries) {
|
|
// Skip if webhook is inactive or max retries exceeded
|
|
if (!delivery.webhook.isActive) continue
|
|
if (delivery.attempts >= delivery.webhook.maxRetries) continue
|
|
|
|
try {
|
|
await deliverWebhook(delivery.id)
|
|
retried++
|
|
} catch (error) {
|
|
console.error(`[Webhook] Retry failed for delivery ${delivery.id}:`, error)
|
|
errors++
|
|
}
|
|
}
|
|
|
|
return { retried, errors }
|
|
}
|
|
|
|
/**
|
|
* Generate a random HMAC secret for webhook signing.
|
|
*/
|
|
export function generateWebhookSecret(): string {
|
|
return crypto.randomBytes(32).toString('hex')
|
|
}
|