diff --git a/docker-stack/docker-compose-standalone.yaml b/docker-stack/docker-compose-standalone.yaml index eaae6c1..7e763e0 100644 --- a/docker-stack/docker-compose-standalone.yaml +++ b/docker-stack/docker-compose-standalone.yaml @@ -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: diff --git a/docker-stack/docker-compose-swarm.yml b/docker-stack/docker-compose-swarm.yml index 265addf..e7467f8 100644 --- a/docker-stack/docker-compose-swarm.yml +++ b/docker-stack/docker-compose-swarm.yml @@ -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 diff --git a/mailgun-webhook/src/routes/webhookRouter.ts b/mailgun-webhook/src/routes/webhookRouter.ts index b327d74..98122c7 100644 --- a/mailgun-webhook/src/routes/webhookRouter.ts +++ b/mailgun-webhook/src/routes/webhookRouter.ts @@ -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); diff --git a/mailgun-webhook/src/types/MailgunWebhookEvent.ts b/mailgun-webhook/src/types/MailgunWebhookEvent.ts index a41c908..cc99312 100644 --- a/mailgun-webhook/src/types/MailgunWebhookEvent.ts +++ b/mailgun-webhook/src/types/MailgunWebhookEvent.ts @@ -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' + ); +} \ No newline at end of file