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_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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user