Skip to content

Commit c027095

Browse files
authored
Merge pull request #674 from Ajibose/test/webhook-signature-timing-safe-comparison
test(webhooks): add unit tests for verifyWebhookSignature timing-safe comparison
2 parents 423f4e9 + 65b5ad4 commit c027095

2 files changed

Lines changed: 104 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import crypto from 'crypto';
2+
import { verifyWebhookSignature } from './webhook-signature.utils';
3+
4+
// ── Helpers ───────────────────────────────────────────────────────────────────
5+
6+
const secret = 'test-webhook-signing-secret';
7+
const payload = Buffer.from(JSON.stringify({ event_type: 'buy', amount: '10' }));
8+
9+
function computeValidHeader(): string {
10+
const hex = crypto.createHmac('sha256', secret).update(payload).digest('hex');
11+
return `sha256=${hex}`;
12+
}
13+
14+
function flipHexChar(char: string): string {
15+
return char === '0' ? '1' : '0';
16+
}
17+
18+
// ── Tests ─────────────────────────────────────────────────────────────────────
19+
20+
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+
});
71+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import crypto from 'crypto';
2+
3+
const SIGNATURE_HEADER_PATTERN = /^sha256=([0-9a-f]+)$/i;
4+
5+
/**
6+
* Verifies an incoming webhook's `sha256=<hex>` HMAC signature header against
7+
* the raw request payload using a constant-time comparison.
8+
*/
9+
export function verifyWebhookSignature(
10+
payload: Buffer,
11+
header: string,
12+
secret: string
13+
): boolean {
14+
if (!header) return false;
15+
16+
const match = SIGNATURE_HEADER_PATTERN.exec(header.trim());
17+
if (!match) return false;
18+
19+
const providedSignature = match[1];
20+
const expectedSignature = crypto
21+
.createHmac('sha256', secret)
22+
.update(payload)
23+
.digest('hex');
24+
25+
const providedBuffer = Buffer.from(providedSignature, 'utf8');
26+
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
27+
28+
if (providedBuffer.length !== expectedBuffer.length) {
29+
return false;
30+
}
31+
32+
return crypto.timingSafeEqual(providedBuffer, expectedBuffer);
33+
}

0 commit comments

Comments
 (0)