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>
This commit is contained in:
@@ -88,6 +88,7 @@ services:
|
||||
PROMETHEUS_APP_LABEL: mailgun-webhook-service
|
||||
PROMETHEUS_HISTOGRAM_BUCKETS: 0.1,0.5,1,5,10
|
||||
DEBUG: server:*,app:*
|
||||
MAILGUN_WEBHOOK_SIGNING_KEY: ${MAILGUN_WEBHOOK_SIGNING_KEY}
|
||||
container_name: evidencija-rezija__mailgun-webhook
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
|
||||
@@ -88,6 +88,7 @@ services:
|
||||
PROMETHEUS_APP_LABEL: mailgun-webhook-service
|
||||
PROMETHEUS_HISTOGRAM_BUCKETS: 0.1,0.5,1,5,10
|
||||
DEBUG: server:*,app:*
|
||||
MAILGUN_WEBHOOK_SIGNING_KEY: ${MAILGUN_WEBHOOK_SIGNING_KEY}
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: any
|
||||
|
||||
@@ -9,9 +9,31 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import createError from 'http-errors';
|
||||
import { AppLocals } from '../types/AppLocals';
|
||||
import { isValidMailgunEvent, MailgunWebhookEvent } from '../types/MailgunWebhookEvent';
|
||||
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
|
||||
@@ -117,7 +139,16 @@ const logWebhookEvent = (eventData: MailgunWebhookEvent): void => {
|
||||
*/
|
||||
export const webhookRequestHandler = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const eventData = req.body as any;
|
||||
|
||||
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)) {
|
||||
@@ -126,6 +157,19 @@ export const webhookRequestHandler = async (req: Request, res: Response, next: N
|
||||
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);
|
||||
|
||||
|
||||
@@ -14,6 +14,17 @@ export type MailgunEventType =
|
||||
| 'complained'
|
||||
| 'unsubscribed';
|
||||
|
||||
export interface MailgunWebhookPayload {
|
||||
signature: MailgunWebhookPayloadSignature;
|
||||
"event-data": MailgunWebhookEvent;
|
||||
}
|
||||
|
||||
export interface MailgunWebhookPayloadSignature {
|
||||
token: string;
|
||||
timestamp: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for all MailGun webhook events
|
||||
* Contains fields common to all event types
|
||||
@@ -163,3 +174,15 @@ export function isValidMailgunEvent(data: any): data is MailgunWebhookEventBase
|
||||
typeof data.recipient === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if data is a MailgunWebhookPayload
|
||||
*/
|
||||
export function isMailgunWebhookPayload(data: any): data is MailgunWebhookPayload {
|
||||
return (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
typeof data.signature === 'object' &&
|
||||
typeof data['event-data'] === 'object'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user