From 865a04bdbeec6b8a06fa9de80cba521288422fe3 Mon Sep 17 00:00:00 2001 From: Sylvester Menawar Date: Thu, 27 Aug 2026 10:56:17 +0100 Subject: [PATCH] feat: implement webhook signature verification middleware with key rotation Closes #1001 - Added `createWebhookSignatureMiddleware` in `backend/services/shared` to verify incoming webhook signatures. - Supports dynamically resolving secrets to seamlessly handle key rotation overlaps. - Included comprehensive test suite covering missing headers, raw body handling, valid/invalid signatures, and rotation windows. --- .../webhookSignatureMiddleware.test.ts | 172 ++++++++++++++++++ backend/services/shared/index.ts | 1 + .../shared/webhookSignatureMiddleware.ts | 105 +++++++++++ 3 files changed, 278 insertions(+) create mode 100644 backend/services/shared/__tests__/webhookSignatureMiddleware.test.ts create mode 100644 backend/services/shared/webhookSignatureMiddleware.ts diff --git a/backend/services/shared/__tests__/webhookSignatureMiddleware.test.ts b/backend/services/shared/__tests__/webhookSignatureMiddleware.test.ts new file mode 100644 index 00000000..711511f2 --- /dev/null +++ b/backend/services/shared/__tests__/webhookSignatureMiddleware.test.ts @@ -0,0 +1,172 @@ +import { Request, Response } from 'express'; +import crypto from 'crypto'; +import { createWebhookSignatureMiddleware } from '../webhookSignatureMiddleware'; +import { WebhookSecret } from '../../../../src/types/webhook'; + +describe('createWebhookSignatureMiddleware', () => { + let req: Partial; + let res: Partial; + let next: jest.Mock; + + beforeEach(() => { + req = { + headers: {}, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + next = jest.fn(); + }); + + const signPayload = (payload: string, secret: string, includePrefix = false) => { + const hmac = crypto.createHmac('sha256', secret); + hmac.update(Buffer.from(payload, 'utf8')); + const hex = hmac.digest('hex'); + return includePrefix ? `sha256=${hex}` : hex; + }; + + it('should return 401 if signature header is missing', async () => { + const middleware = createWebhookSignatureMiddleware({ + secrets: [{ key: 'secret', validFrom: 0, createdAt: 0 }], + }); + + await middleware(req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Missing X-SubTrackr-Signature header' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 500 if raw body is missing', async () => { + req.headers!['x-subtrackr-signature'] = 'dummy-signature'; + + const middleware = createWebhookSignatureMiddleware({ + secrets: [{ key: 'secret', validFrom: 0, createdAt: 0 }], + }); + + await middleware(req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Raw request body not available for signature verification' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should verify valid signature correctly without prefix', async () => { + const payload = JSON.stringify({ event: 'test' }); + const secret = 'my-secret-key'; + const signature = signPayload(payload, secret); + + req.headers!['x-subtrackr-signature'] = signature; + (req as any).rawBody = payload; + + const middleware = createWebhookSignatureMiddleware({ + secrets: [{ key: secret, validFrom: 0, createdAt: 0 }], + }); + + await middleware(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledWith(); // success + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should verify valid signature correctly with sha256= prefix', async () => { + const payload = JSON.stringify({ event: 'test' }); + const secret = 'my-secret-key'; + const signature = signPayload(payload, secret, true); // sha256=... + + req.headers!['x-subtrackr-signature'] = signature; + (req as any).rawBody = payload; + + const middleware = createWebhookSignatureMiddleware({ + secrets: [{ key: secret, validFrom: 0, createdAt: 0 }], + }); + + await middleware(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledWith(); // success + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should return 401 for an invalid signature', async () => { + const payload = JSON.stringify({ event: 'test' }); + const signature = signPayload(payload, 'wrong-secret'); + + req.headers!['x-subtrackr-signature'] = signature; + (req as any).rawBody = payload; + + const middleware = createWebhookSignatureMiddleware({ + secrets: [{ key: 'my-secret-key', validFrom: 0, createdAt: 0 }], + }); + + await middleware(req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid webhook signature' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should support dynamic retrieval of secrets for key rotation', async () => { + const payload = JSON.stringify({ event: 'test' }); + const secret1 = 'old-secret-key'; + const secret2 = 'new-secret-key'; + const signatureForOld = signPayload(payload, secret1); + + req.headers!['x-subtrackr-signature'] = signatureForOld; + (req as any).rawBody = payload; + + // Both secrets valid (during rotation overlap) + const getSecrets = jest.fn().mockResolvedValue([ + { key: secret1, validFrom: 0, createdAt: 0 }, + { key: secret2, validFrom: 0, createdAt: 0 } + ]); + + const middleware = createWebhookSignatureMiddleware({ secrets: getSecrets }); + + await middleware(req as Request, res as Response, next); + + expect(getSecrets).toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); // Should succeed with old secret + }); + + it('should ignore secrets that are expired or not yet valid', async () => { + const payload = JSON.stringify({ event: 'test' }); + const secretExpired = 'expired-key'; + const signature = signPayload(payload, secretExpired); + + req.headers!['x-subtrackr-signature'] = signature; + (req as any).rawBody = payload; + + const now = Date.now(); + const secrets: WebhookSecret[] = [ + { key: secretExpired, validFrom: 0, validUntil: now - 10000, createdAt: 0 }, // expired + { key: 'future-key', validFrom: now + 10000, createdAt: 0 }, // not valid yet + ]; + + const middleware = createWebhookSignatureMiddleware({ secrets }); + + await middleware(req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'No valid webhook secrets configured' }); + }); + + it('should allow custom header name and raw body extractor', async () => { + const payload = Buffer.from(JSON.stringify({ event: 'test' }), 'utf8'); + const secret = 'secret-key'; + const signature = signPayload(payload.toString('utf8'), secret); + + req.headers!['x-custom-signature'] = signature; + (req as any).customRawBody = payload; // Custom location + + const middleware = createWebhookSignatureMiddleware({ + secrets: [{ key: secret, validFrom: 0, createdAt: 0 }], + headerName: 'X-Custom-Signature', + getRawBody: (req) => (req as any).customRawBody, + }); + + await middleware(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledWith(); + }); +}); diff --git a/backend/services/shared/index.ts b/backend/services/shared/index.ts index 7d632391..36e12dde 100644 --- a/backend/services/shared/index.ts +++ b/backend/services/shared/index.ts @@ -169,3 +169,4 @@ export type { LeakRecord, PoolTuningRecommendation, } from './poolMonitor'; +export * from './webhookSignatureMiddleware'; diff --git a/backend/services/shared/webhookSignatureMiddleware.ts b/backend/services/shared/webhookSignatureMiddleware.ts new file mode 100644 index 00000000..2d76bd36 --- /dev/null +++ b/backend/services/shared/webhookSignatureMiddleware.ts @@ -0,0 +1,105 @@ +import { Request, Response, NextFunction } from 'express'; +import crypto from 'crypto'; +import { WebhookSecret } from '../../../src/types/webhook'; + +export interface WebhookSignatureOptions { + /** + * The active signing secrets. Can be a static list or a function that dynamically + * retrieves the list (e.g., from a database) to support key rotation. + */ + secrets: WebhookSecret[] | (() => Promise | WebhookSecret[]); + /** + * The name of the header containing the signature. + * Defaults to 'X-SubTrackr-Signature'. + */ + headerName?: string; + /** + * By default, the middleware expects the raw body buffer to be available on `req.rawBody`. + * You can override this to extract the raw payload string/buffer from the request. + */ + getRawBody?: (req: Request) => Buffer | string | undefined; +} + +/** + * Creates an Express middleware that verifies incoming webhook signatures. + * Supports key rotation by checking the signature against all currently valid secrets. + * + * Note: To use this middleware effectively, the raw request body must be preserved. + * You can do this with `express.json({ verify: (req, res, buf) => { (req as any).rawBody = buf; } })`. + */ +export function createWebhookSignatureMiddleware(options: WebhookSignatureOptions) { + const headerName = (options.headerName ?? 'X-SubTrackr-Signature').toLowerCase(); + + return async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const signatureHeader = req.headers[headerName]; + const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader; + + if (!signature) { + res.status(401).json({ error: `Missing ${options.headerName ?? 'X-SubTrackr-Signature'} header` }); + return; + } + + // Extract raw body + const rawBody = options.getRawBody + ? options.getRawBody(req) + : (req as any).rawBody; + + if (!rawBody) { + res.status(500).json({ error: 'Raw request body not available for signature verification' }); + return; + } + + // Retrieve secrets (supporting dynamic retrieval for key rotation) + const secrets = typeof options.secrets === 'function' ? await options.secrets() : options.secrets; + + const now = Date.now(); + const validSecrets = secrets.filter(secret => { + if (now < secret.validFrom) return false; + if (secret.validUntil !== undefined && now > secret.validUntil) return false; + return true; + }); + + if (validSecrets.length === 0) { + res.status(401).json({ error: 'No valid webhook secrets configured' }); + return; + } + + const bodyBuffer = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8'); + + // Some webhook senders prefix the signature (e.g. sha256=...) + // The SubTrackr backend sends raw hex, but we handle the prefix if present. + let actualSignatureHex = signature; + if (signature.startsWith('sha256=')) { + actualSignatureHex = signature.slice(7); + } + const actualSignatureBytes = Buffer.from(actualSignatureHex, 'hex'); + + let isValid = false; + for (const secret of validSecrets) { + // SubTrackr signs using HMAC SHA-256 + const hmac = crypto.createHmac('sha256', secret.key); + hmac.update(bodyBuffer); + const expectedSignatureHex = hmac.digest('hex'); + const expectedSignatureBytes = Buffer.from(expectedSignatureHex, 'hex'); + + if ( + actualSignatureBytes.length === expectedSignatureBytes.length && + crypto.timingSafeEqual(actualSignatureBytes, expectedSignatureBytes) + ) { + isValid = true; + break; + } + } + + if (!isValid) { + res.status(401).json({ error: 'Invalid webhook signature' }); + return; + } + + next(); + } catch (err) { + next(err); + } + }; +}