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:
Knee Cola
2026-01-07 22:13:09 +01:00
parent 0faac8e392
commit 3d02654510
4 changed files with 71 additions and 2 deletions

View File

@@ -88,6 +88,7 @@ services:
PROMETHEUS_APP_LABEL: mailgun-webhook-service PROMETHEUS_APP_LABEL: mailgun-webhook-service
PROMETHEUS_HISTOGRAM_BUCKETS: 0.1,0.5,1,5,10 PROMETHEUS_HISTOGRAM_BUCKETS: 0.1,0.5,1,5,10
DEBUG: server:*,app:* DEBUG: server:*,app:*
MAILGUN_WEBHOOK_SIGNING_KEY: ${MAILGUN_WEBHOOK_SIGNING_KEY}
container_name: evidencija-rezija__mailgun-webhook container_name: evidencija-rezija__mailgun-webhook
restart: unless-stopped restart: unless-stopped
labels: labels:

View File

@@ -88,6 +88,7 @@ services:
PROMETHEUS_APP_LABEL: mailgun-webhook-service PROMETHEUS_APP_LABEL: mailgun-webhook-service
PROMETHEUS_HISTOGRAM_BUCKETS: 0.1,0.5,1,5,10 PROMETHEUS_HISTOGRAM_BUCKETS: 0.1,0.5,1,5,10
DEBUG: server:*,app:* DEBUG: server:*,app:*
MAILGUN_WEBHOOK_SIGNING_KEY: ${MAILGUN_WEBHOOK_SIGNING_KEY}
deploy: deploy:
restart_policy: restart_policy:
condition: any condition: any

View File

@@ -9,9 +9,31 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import createError from 'http-errors'; import createError from 'http-errors';
import { AppLocals } from '../types/AppLocals'; 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 { successfulRequestCounter } from '../lib/metricsCounters';
import { logInfo, logError, logWarn } from '../lib/logger'; 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 * 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) => { export const webhookRequestHandler = async (req: Request, res: Response, next: NextFunction) => {
try { 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 // Validate that we have the minimum required fields
if (!isValidMailgunEvent(eventData)) { if (!isValidMailgunEvent(eventData)) {
@@ -126,6 +157,19 @@ export const webhookRequestHandler = async (req: Request, res: Response, next: N
return; 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 // Log the webhook event to console
logWebhookEvent(eventData as MailgunWebhookEvent); logWebhookEvent(eventData as MailgunWebhookEvent);

View File

@@ -14,6 +14,17 @@ export type MailgunEventType =
| 'complained' | 'complained'
| 'unsubscribed'; | '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 * Base interface for all MailGun webhook events
* Contains fields common to all event types * Contains fields common to all event types
@@ -163,3 +174,15 @@ export function isValidMailgunEvent(data: any): data is MailgunWebhookEventBase
typeof data.recipient === 'string' 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'
);
}