Skip to content

Commit a08c14a

Browse files
committed
feat(webhooks): add helper and tests for verifying incoming webhook HMAC signature (#663)
1 parent f97085b commit a08c14a

2 files changed

Lines changed: 75 additions & 54 deletions

File tree

src/modules/webhooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { default as webhookRouter } from './webhook.router';
22
export * from './webhook.types';
33
export { dispatchWebhookEvent } from './webhook.service';
4+
export { verifyWebhookSignature } from './webhook-signature.utils';

src/modules/webhooks/webhook-signature.utils.test.ts

Lines changed: 74 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,68 +4,88 @@ import { verifyWebhookSignature } from './webhook-signature.utils';
44
// ── Helpers ───────────────────────────────────────────────────────────────────
55

66
const secret = 'test-webhook-signing-secret';
7-
const payload = Buffer.from(JSON.stringify({ event_type: 'buy', amount: '10' }));
7+
const payload = Buffer.from(
8+
JSON.stringify({ event_type: 'buy', amount: '10' })
9+
);
810

911
function computeValidHeader(): string {
10-
const hex = crypto.createHmac('sha256', secret).update(payload).digest('hex');
11-
return `sha256=${hex}`;
12+
const hex = crypto
13+
.createHmac('sha256', secret)
14+
.update(payload)
15+
.digest('hex');
16+
return `sha256=${hex}`;
1217
}
1318

1419
function flipHexChar(char: string): string {
15-
return char === '0' ? '1' : '0';
20+
return char === '0' ? '1' : '0';
1621
}
1722

1823
// ── Tests ─────────────────────────────────────────────────────────────────────
1924

2025
describe('verifyWebhookSignature', () => {
21-
it('returns true for a valid signature', () => {
22-
expect(verifyWebhookSignature(payload, computeValidHeader(), secret)).toBe(true);
23-
});
24-
25-
it('returns false when the signature differs in the last character', () => {
26-
const header = computeValidHeader();
27-
const tampered =
28-
header.slice(0, -1) + flipHexChar(header[header.length - 1]);
29-
30-
expect(verifyWebhookSignature(payload, tampered, secret)).toBe(false);
31-
});
32-
33-
it('returns false when the signature differs in the first character', () => {
34-
const header = computeValidHeader();
35-
const prefix = 'sha256=';
36-
const firstHexChar = header[prefix.length];
37-
const tampered =
38-
prefix + flipHexChar(firstHexChar) + header.slice(prefix.length + 1);
39-
40-
expect(verifyWebhookSignature(payload, tampered, secret)).toBe(false);
41-
});
42-
43-
it('returns false without throwing when the signature is one character shorter than expected', () => {
44-
const header = computeValidHeader();
45-
const shortened = header.slice(0, -1);
46-
47-
expect(() => verifyWebhookSignature(payload, shortened, secret)).not.toThrow();
48-
expect(verifyWebhookSignature(payload, shortened, secret)).toBe(false);
49-
});
50-
51-
it('returns false without throwing when the signature is one character longer than expected', () => {
52-
const header = computeValidHeader();
53-
const lengthened = `${header}a`;
54-
55-
expect(() => verifyWebhookSignature(payload, lengthened, secret)).not.toThrow();
56-
expect(verifyWebhookSignature(payload, lengthened, secret)).toBe(false);
57-
});
58-
59-
it('returns false without throwing for a malformed header', () => {
60-
expect(() =>
61-
verifyWebhookSignature(payload, 'not-a-valid-header', secret)
62-
).not.toThrow();
63-
expect(verifyWebhookSignature(payload, 'not-a-valid-header', secret)).toBe(
64-
false
65-
);
66-
});
67-
68-
it('returns false for an empty header', () => {
69-
expect(verifyWebhookSignature(payload, '', secret)).toBe(false);
70-
});
26+
it('returns true for a valid signature', () => {
27+
expect(
28+
verifyWebhookSignature(payload, computeValidHeader(), secret)
29+
).toBe(true);
30+
});
31+
32+
it('returns false for a tampered payload', () => {
33+
const tamperedPayload = Buffer.from(
34+
JSON.stringify({ event_type: 'buy', amount: '999' })
35+
);
36+
expect(
37+
verifyWebhookSignature(tamperedPayload, computeValidHeader(), secret)
38+
).toBe(false);
39+
});
40+
41+
it('returns false when the signature differs in the last character', () => {
42+
const header = computeValidHeader();
43+
const tampered =
44+
header.slice(0, -1) + flipHexChar(header[header.length - 1]);
45+
46+
expect(verifyWebhookSignature(payload, tampered, secret)).toBe(false);
47+
});
48+
49+
it('returns false when the signature differs in the first character', () => {
50+
const header = computeValidHeader();
51+
const prefix = 'sha256=';
52+
const firstHexChar = header[prefix.length];
53+
const tampered =
54+
prefix + flipHexChar(firstHexChar) + header.slice(prefix.length + 1);
55+
56+
expect(verifyWebhookSignature(payload, tampered, secret)).toBe(false);
57+
});
58+
59+
it('returns false without throwing when the signature is one character shorter than expected', () => {
60+
const header = computeValidHeader();
61+
const shortened = header.slice(0, -1);
62+
63+
expect(() =>
64+
verifyWebhookSignature(payload, shortened, secret)
65+
).not.toThrow();
66+
expect(verifyWebhookSignature(payload, shortened, secret)).toBe(false);
67+
});
68+
69+
it('returns false without throwing when the signature is one character longer than expected', () => {
70+
const header = computeValidHeader();
71+
const lengthened = `${header}a`;
72+
73+
expect(() =>
74+
verifyWebhookSignature(payload, lengthened, secret)
75+
).not.toThrow();
76+
expect(verifyWebhookSignature(payload, lengthened, secret)).toBe(false);
77+
});
78+
79+
it('returns false without throwing for a malformed header', () => {
80+
expect(() =>
81+
verifyWebhookSignature(payload, 'not-a-valid-header', secret)
82+
).not.toThrow();
83+
expect(
84+
verifyWebhookSignature(payload, 'not-a-valid-header', secret)
85+
).toBe(false);
86+
});
87+
88+
it('returns false for an empty header', () => {
89+
expect(verifyWebhookSignature(payload, '', secret)).toBe(false);
90+
});
7191
});

0 commit comments

Comments
 (0)