Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions backend/services/shared/__tests__/webhookSignatureMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -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<Request>;
let res: Partial<Response>;
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();
});
});
1 change: 1 addition & 0 deletions backend/services/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,4 @@ export type {
LeakRecord,
PoolTuningRecommendation,
} from './poolMonitor';
export * from './webhookSignatureMiddleware';
105 changes: 105 additions & 0 deletions backend/services/shared/webhookSignatureMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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[]> | 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<void> => {
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);
}
};
}
Loading