Files
evidencija-rezija/mailgun-webhook/src/routes/webhookRouter.ts
Knee Cola 3d02654510 feat: add webhook signature verification and fix security issues
Security Improvements:
- Add HMAC-SHA256 signature verification for MailGun webhooks
- Remove hardcoded signing key fallback, require env variable
- Add proper payload structure validation before processing

API Changes:
- New types: MailgunWebhookPayload, MailgunWebhookPayloadSignature
- New type guard: isMailgunWebhookPayload()
- Returns 401 for invalid signatures, 400 for malformed payloads

Configuration:
- Add MAILGUN_WEBHOOK_SIGNING_KEY to both docker-compose files
- Service fails fast on startup if signing key not configured

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:13:09 +01:00

202 lines
7.1 KiB
TypeScript

/**
* MailGun Webhook Router
*
* Handles incoming webhook events from MailGun and logs them to the console.
* This router processes POST requests containing email event data such as
* delivered, failed, opened, clicked, bounced, complained, and unsubscribed events.
*/
import { Router, Request, Response, NextFunction } from 'express';
import createError from 'http-errors';
import { AppLocals } from '../types/AppLocals';
import { isMailgunWebhookPayload, isValidMailgunEvent, MailgunWebhookEvent, MailgunWebhookPayloadSignature } from '../types/MailgunWebhookEvent';
import { successfulRequestCounter } from '../lib/metricsCounters';
import { logInfo, logError, logWarn } from '../lib/logger';
import crypto from 'crypto';
const WEBHOOK_SIGNING_KEY = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;
if (!WEBHOOK_SIGNING_KEY) {
logError('Configuration Error', 'MAILGUN_WEBHOOK_SIGNING_KEY environment variable is not set');
throw new Error('MAILGUN_WEBHOOK_SIGNING_KEY environment variable is required');
}
/**
* Verifies the MailGun webhook signature
* @param param0 - Object containing signingKey, timestamp, token, and signature
* @returns boolean indicating if the signature is valid
*/
const verifySignature = ({ timestamp, token, signature }: MailgunWebhookPayloadSignature) => {
const encodedToken = crypto
.createHmac('sha256', WEBHOOK_SIGNING_KEY)
.update(timestamp.concat(token))
.digest('hex')
return (encodedToken === signature)
}
/**
* Formats a Unix timestamp into a human-readable date string
* @param timestamp - Unix timestamp as string
* @returns Formatted date string in ISO format
*/
const formatTimestamp = (timestamp: string): string => {
try {
const timestampNum = parseInt(timestamp, 10);
if (isNaN(timestampNum)) {
return 'Invalid timestamp';
}
return new Date(timestampNum * 1000).toISOString();
} catch (error) {
return 'Invalid timestamp';
}
};
/**
* Logs MailGun webhook event data to console with structured formatting
* @param eventData - The MailGun webhook event data
*/
const logWebhookEvent = (eventData: MailgunWebhookEvent): void => {
const separator = '========================================';
const minorSeparator = '----------------------------------------';
console.log('\n' + separator);
console.log('MailGun Webhook Event Received');
console.log(separator);
console.log(`Event Type: ${eventData.event}`);
console.log(`Timestamp: ${eventData.timestamp} (${formatTimestamp(eventData.timestamp)})`);
console.log(`Recipient: ${eventData.recipient}`);
console.log(`Domain: ${eventData.domain}`);
if (eventData['message-id']) {
console.log(`MailGun Message ID: ${eventData['message-id']}`);
}
if (eventData['Message-Id']) {
console.log(`SMTP Message ID: ${eventData['Message-Id']}`);
}
// Log event-specific data
console.log(minorSeparator);
console.log('Event-Specific Data:');
switch (eventData.event) {
case 'delivered':
if (eventData['message-headers']) {
console.log(`Message Headers: ${eventData['message-headers'].substring(0, 200)}...`);
}
break;
case 'failed':
if (eventData.severity) console.log(`Severity: ${eventData.severity}`);
if (eventData.reason) console.log(`Reason: ${eventData.reason}`);
if (eventData.notification) console.log(`Notification: ${eventData.notification}`);
break;
case 'opened':
if (eventData.city) console.log(`City: ${eventData.city}`);
if (eventData.country) console.log(`Country: ${eventData.country}`);
if (eventData['device-type']) console.log(`Device Type: ${eventData['device-type']}`);
if (eventData['client-os']) console.log(`Client OS: ${eventData['client-os']}`);
if (eventData['client-name']) console.log(`Client Name: ${eventData['client-name']}`);
if (eventData.ip) console.log(`IP Address: ${eventData.ip}`);
break;
case 'clicked':
console.log(`URL Clicked: ${eventData.url}`);
if (eventData.city) console.log(`City: ${eventData.city}`);
if (eventData.country) console.log(`Country: ${eventData.country}`);
if (eventData['device-type']) console.log(`Device Type: ${eventData['device-type']}`);
if (eventData['client-os']) console.log(`Client OS: ${eventData['client-os']}`);
if (eventData['client-name']) console.log(`Client Name: ${eventData['client-name']}`);
if (eventData.ip) console.log(`IP Address: ${eventData.ip}`);
break;
case 'bounced':
if (eventData.code) console.log(`SMTP Code: ${eventData.code}`);
if (eventData.error) console.log(`Error: ${eventData.error}`);
if (eventData.notification) console.log(`Notification: ${eventData.notification}`);
break;
case 'complained':
console.log('User marked email as spam');
break;
case 'unsubscribed':
console.log('User unsubscribed from mailing list');
break;
}
// Log full event data for debugging
console.log(minorSeparator);
console.log('Full Event Data (JSON):');
console.log(JSON.stringify(eventData, null, 2));
console.log(separator + '\n');
};
/**
* Main webhook request handler
* Processes incoming MailGun webhook events and logs them to console
*/
export const webhookRequestHandler = async (req: Request, res: Response, next: NextFunction) => {
try {
const payload = req.body as any;
if(!isMailgunWebhookPayload(payload)) {
logWarn('Invalid webhook payload structure', JSON.stringify(payload));
next(createError(400, 'Invalid request format: payload structure is incorrect'));
return;
}
const { signature, "event-data": eventData } = payload;
// Validate that we have the minimum required fields
if (!isValidMailgunEvent(eventData)) {
logWarn('Invalid webhook event received', JSON.stringify(eventData));
next(createError(400, 'Invalid request format: missing required fields (event, recipient)'));
return;
}
// Verify webhook signature
const isValidSignature = verifySignature({
timestamp: signature.timestamp,
token: signature.token,
signature: signature.signature
});
if(!isValidSignature) {
logWarn('Invalid webhook signature', JSON.stringify(signature));
next(createError(401, 'Unauthorized: invalid webhook signature'));
return;
}
// Log the webhook event to console
logWebhookEvent(eventData as MailgunWebhookEvent);
// Log using the structured logger as well
logInfo('MailGun webhook event processed', `Event: ${eventData.event}, Recipient: ${eventData.recipient}`);
// Return success response to MailGun
res.status(200).json({
status: 'received',
message: 'Webhook event logged successfully'
});
// Increment successful requests counter for metrics
successfulRequestCounter.inc();
} catch (ex: any) {
logError('Error processing webhook', ex.message);
next(createError(500, 'Internal server error'));
}
// Stop Prometheus timer
(res.locals as AppLocals).stopPrometheusTimer({ path: req.path });
};
/**
* Express router for MailGun webhook endpoint
*/
const router = Router();
export const webhookRouter = router.post('/', webhookRequestHandler);