Skip to content

Commit ce5eaa5

Browse files
authored
Merge pull request #1038 from menawar/feat/webhook-signature-middleware-issue-1001
feat: implement webhook signature verification middleware with key rotation
2 parents b9617e2 + 6ed47c5 commit ce5eaa5

2 files changed

Lines changed: 277 additions & 0 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { Request, Response } from 'express';
2+
import crypto from 'crypto';
3+
import { createWebhookSignatureMiddleware } from '../webhookSignatureMiddleware';
4+
import { WebhookSecret } from '../../../../src/types/webhook';
5+
6+
describe('createWebhookSignatureMiddleware', () => {
7+
let req: Partial<Request>;
8+
let res: Partial<Response>;
9+
let next: jest.Mock;
10+
11+
beforeEach(() => {
12+
req = {
13+
headers: {},
14+
};
15+
res = {
16+
status: jest.fn().mockReturnThis(),
17+
json: jest.fn(),
18+
};
19+
next = jest.fn();
20+
});
21+
22+
const signPayload = (payload: string, secret: string, includePrefix = false) => {
23+
const hmac = crypto.createHmac('sha256', secret);
24+
hmac.update(Buffer.from(payload, 'utf8'));
25+
const hex = hmac.digest('hex');
26+
return includePrefix ? `sha256=${hex}` : hex;
27+
};
28+
29+
it('should return 401 if signature header is missing', async () => {
30+
const middleware = createWebhookSignatureMiddleware({
31+
secrets: [{ key: 'secret', validFrom: 0, createdAt: 0 }],
32+
});
33+
34+
await middleware(req as Request, res as Response, next);
35+
36+
expect(res.status).toHaveBeenCalledWith(401);
37+
expect(res.json).toHaveBeenCalledWith({ error: 'Missing X-SubTrackr-Signature header' });
38+
expect(next).not.toHaveBeenCalled();
39+
});
40+
41+
it('should return 500 if raw body is missing', async () => {
42+
req.headers!['x-subtrackr-signature'] = 'dummy-signature';
43+
44+
const middleware = createWebhookSignatureMiddleware({
45+
secrets: [{ key: 'secret', validFrom: 0, createdAt: 0 }],
46+
});
47+
48+
await middleware(req as Request, res as Response, next);
49+
50+
expect(res.status).toHaveBeenCalledWith(500);
51+
expect(res.json).toHaveBeenCalledWith({ error: 'Raw request body not available for signature verification' });
52+
expect(next).not.toHaveBeenCalled();
53+
});
54+
55+
it('should verify valid signature correctly without prefix', async () => {
56+
const payload = JSON.stringify({ event: 'test' });
57+
const secret = 'my-secret-key';
58+
const signature = signPayload(payload, secret);
59+
60+
req.headers!['x-subtrackr-signature'] = signature;
61+
(req as any).rawBody = payload;
62+
63+
const middleware = createWebhookSignatureMiddleware({
64+
secrets: [{ key: secret, validFrom: 0, createdAt: 0 }],
65+
});
66+
67+
await middleware(req as Request, res as Response, next);
68+
69+
expect(next).toHaveBeenCalledWith(); // success
70+
expect(res.status).not.toHaveBeenCalled();
71+
});
72+
73+
it('should verify valid signature correctly with sha256= prefix', async () => {
74+
const payload = JSON.stringify({ event: 'test' });
75+
const secret = 'my-secret-key';
76+
const signature = signPayload(payload, secret, true); // sha256=...
77+
78+
req.headers!['x-subtrackr-signature'] = signature;
79+
(req as any).rawBody = payload;
80+
81+
const middleware = createWebhookSignatureMiddleware({
82+
secrets: [{ key: secret, validFrom: 0, createdAt: 0 }],
83+
});
84+
85+
await middleware(req as Request, res as Response, next);
86+
87+
expect(next).toHaveBeenCalledWith(); // success
88+
expect(res.status).not.toHaveBeenCalled();
89+
});
90+
91+
it('should return 401 for an invalid signature', async () => {
92+
const payload = JSON.stringify({ event: 'test' });
93+
const signature = signPayload(payload, 'wrong-secret');
94+
95+
req.headers!['x-subtrackr-signature'] = signature;
96+
(req as any).rawBody = payload;
97+
98+
const middleware = createWebhookSignatureMiddleware({
99+
secrets: [{ key: 'my-secret-key', validFrom: 0, createdAt: 0 }],
100+
});
101+
102+
await middleware(req as Request, res as Response, next);
103+
104+
expect(res.status).toHaveBeenCalledWith(401);
105+
expect(res.json).toHaveBeenCalledWith({ error: 'Invalid webhook signature' });
106+
expect(next).not.toHaveBeenCalled();
107+
});
108+
109+
it('should support dynamic retrieval of secrets for key rotation', async () => {
110+
const payload = JSON.stringify({ event: 'test' });
111+
const secret1 = 'old-secret-key';
112+
const secret2 = 'new-secret-key';
113+
const signatureForOld = signPayload(payload, secret1);
114+
115+
req.headers!['x-subtrackr-signature'] = signatureForOld;
116+
(req as any).rawBody = payload;
117+
118+
// Both secrets valid (during rotation overlap)
119+
const getSecrets = jest.fn().mockResolvedValue([
120+
{ key: secret1, validFrom: 0, createdAt: 0 },
121+
{ key: secret2, validFrom: 0, createdAt: 0 }
122+
]);
123+
124+
const middleware = createWebhookSignatureMiddleware({ secrets: getSecrets });
125+
126+
await middleware(req as Request, res as Response, next);
127+
128+
expect(getSecrets).toHaveBeenCalled();
129+
expect(next).toHaveBeenCalledWith(); // Should succeed with old secret
130+
});
131+
132+
it('should ignore secrets that are expired or not yet valid', async () => {
133+
const payload = JSON.stringify({ event: 'test' });
134+
const secretExpired = 'expired-key';
135+
const signature = signPayload(payload, secretExpired);
136+
137+
req.headers!['x-subtrackr-signature'] = signature;
138+
(req as any).rawBody = payload;
139+
140+
const now = Date.now();
141+
const secrets: WebhookSecret[] = [
142+
{ key: secretExpired, validFrom: 0, validUntil: now - 10000, createdAt: 0 }, // expired
143+
{ key: 'future-key', validFrom: now + 10000, createdAt: 0 }, // not valid yet
144+
];
145+
146+
const middleware = createWebhookSignatureMiddleware({ secrets });
147+
148+
await middleware(req as Request, res as Response, next);
149+
150+
expect(res.status).toHaveBeenCalledWith(401);
151+
expect(res.json).toHaveBeenCalledWith({ error: 'No valid webhook secrets configured' });
152+
});
153+
154+
it('should allow custom header name and raw body extractor', async () => {
155+
const payload = Buffer.from(JSON.stringify({ event: 'test' }), 'utf8');
156+
const secret = 'secret-key';
157+
const signature = signPayload(payload.toString('utf8'), secret);
158+
159+
req.headers!['x-custom-signature'] = signature;
160+
(req as any).customRawBody = payload; // Custom location
161+
162+
const middleware = createWebhookSignatureMiddleware({
163+
secrets: [{ key: secret, validFrom: 0, createdAt: 0 }],
164+
headerName: 'X-Custom-Signature',
165+
getRawBody: (req) => (req as any).customRawBody,
166+
});
167+
168+
await middleware(req as Request, res as Response, next);
169+
170+
expect(next).toHaveBeenCalledWith();
171+
});
172+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { Request, Response, NextFunction } from 'express';
2+
import crypto from 'crypto';
3+
import { WebhookSecret } from '../../../src/types/webhook';
4+
5+
export interface WebhookSignatureOptions {
6+
/**
7+
* The active signing secrets. Can be a static list or a function that dynamically
8+
* retrieves the list (e.g., from a database) to support key rotation.
9+
*/
10+
secrets: WebhookSecret[] | (() => Promise<WebhookSecret[]> | WebhookSecret[]);
11+
/**
12+
* The name of the header containing the signature.
13+
* Defaults to 'X-SubTrackr-Signature'.
14+
*/
15+
headerName?: string;
16+
/**
17+
* By default, the middleware expects the raw body buffer to be available on `req.rawBody`.
18+
* You can override this to extract the raw payload string/buffer from the request.
19+
*/
20+
getRawBody?: (req: Request) => Buffer | string | undefined;
21+
}
22+
23+
/**
24+
* Creates an Express middleware that verifies incoming webhook signatures.
25+
* Supports key rotation by checking the signature against all currently valid secrets.
26+
*
27+
* Note: To use this middleware effectively, the raw request body must be preserved.
28+
* You can do this with `express.json({ verify: (req, res, buf) => { (req as any).rawBody = buf; } })`.
29+
*/
30+
export function createWebhookSignatureMiddleware(options: WebhookSignatureOptions) {
31+
const headerName = (options.headerName ?? 'X-SubTrackr-Signature').toLowerCase();
32+
33+
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
34+
try {
35+
const signatureHeader = req.headers[headerName];
36+
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
37+
38+
if (!signature) {
39+
res.status(401).json({ error: `Missing ${options.headerName ?? 'X-SubTrackr-Signature'} header` });
40+
return;
41+
}
42+
43+
// Extract raw body
44+
const rawBody = options.getRawBody
45+
? options.getRawBody(req)
46+
: (req as any).rawBody;
47+
48+
if (!rawBody) {
49+
res.status(500).json({ error: 'Raw request body not available for signature verification' });
50+
return;
51+
}
52+
53+
// Retrieve secrets (supporting dynamic retrieval for key rotation)
54+
const secrets = typeof options.secrets === 'function' ? await options.secrets() : options.secrets;
55+
56+
const now = Date.now();
57+
const validSecrets = secrets.filter(secret => {
58+
if (now < secret.validFrom) return false;
59+
if (secret.validUntil !== undefined && now > secret.validUntil) return false;
60+
return true;
61+
});
62+
63+
if (validSecrets.length === 0) {
64+
res.status(401).json({ error: 'No valid webhook secrets configured' });
65+
return;
66+
}
67+
68+
const bodyBuffer = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8');
69+
70+
// Some webhook senders prefix the signature (e.g. sha256=...)
71+
// The SubTrackr backend sends raw hex, but we handle the prefix if present.
72+
let actualSignatureHex = signature;
73+
if (signature.startsWith('sha256=')) {
74+
actualSignatureHex = signature.slice(7);
75+
}
76+
const actualSignatureBytes = Buffer.from(actualSignatureHex, 'hex');
77+
78+
let isValid = false;
79+
for (const secret of validSecrets) {
80+
// SubTrackr signs using HMAC SHA-256
81+
const hmac = crypto.createHmac('sha256', secret.key);
82+
hmac.update(bodyBuffer);
83+
const expectedSignatureHex = hmac.digest('hex');
84+
const expectedSignatureBytes = Buffer.from(expectedSignatureHex, 'hex');
85+
86+
if (
87+
actualSignatureBytes.length === expectedSignatureBytes.length &&
88+
crypto.timingSafeEqual(actualSignatureBytes, expectedSignatureBytes)
89+
) {
90+
isValid = true;
91+
break;
92+
}
93+
}
94+
95+
if (!isValid) {
96+
res.status(401).json({ error: 'Invalid webhook signature' });
97+
return;
98+
}
99+
100+
next();
101+
} catch (err) {
102+
next(err);
103+
}
104+
};
105+
}

0 commit comments

Comments
 (0)