From b17016e381af7bc60216cd6f331dc1d9cbe81480 Mon Sep 17 00:00:00 2001 From: flcrom <161213233+flcrom@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:31:10 +0530 Subject: [PATCH] fix: reject malformed and future webhook timestamps --- src/actions/actions.spec.ts | 2 +- src/common/crypto/signature-provider.spec.ts | 69 +++++++++++++++++++- src/common/crypto/signature-provider.ts | 7 +- src/webhooks/webhooks.spec.ts | 2 +- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/actions/actions.spec.ts b/src/actions/actions.spec.ts index 128fcaae0..8a71ebd91 100644 --- a/src/actions/actions.spec.ts +++ b/src/actions/actions.spec.ts @@ -14,7 +14,7 @@ describe('Actions', () => { }); const makeSigHeader = (payload: unknown, secret: string) => { - const timestamp = Date.now() * 1000; + const timestamp = Date.now(); const unhashedString = `${timestamp}.${JSON.stringify(payload)}`; const signatureHash = crypto .createHmac('sha256', secret) diff --git a/src/common/crypto/signature-provider.spec.ts b/src/common/crypto/signature-provider.spec.ts index 7244dbe43..3b8286baa 100644 --- a/src/common/crypto/signature-provider.spec.ts +++ b/src/common/crypto/signature-provider.spec.ts @@ -13,7 +13,7 @@ describe('SignatureProvider', () => { beforeEach(() => { payload = mockWebhook; secret = 'secret'; - timestamp = Date.now() * 1000; + timestamp = Date.now(); const unhashedString = `${timestamp}.${JSON.stringify(payload)}`; signatureHash = crypto .createHmac('sha256', secret) @@ -23,6 +23,73 @@ describe('SignatureProvider', () => { }); describe('verifyHeader', () => { + it.each([ + 'not-a-timestamp', + '', + '123junk', + 'Infinity', + '1e12', + '1.5', + '9007199254740992', + ])('rejects malformed timestamp %s', async (value) => { + const hash = await signatureProvider.computeSignature( + value, + payload, + secret, + ); + await expect( + signatureProvider.verifyHeader({ + payload, + secret, + sigHeader: `t=${value},v1=${hash}`, + }), + ).rejects.toThrow('Invalid timestamp'); + }); + + it.each([-180000, 0, 180000])( + 'accepts timestamp offset %s within tolerance', + async (offset) => { + const now = 1791600000000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const value = String(now + offset); + const hash = await signatureProvider.computeSignature( + value, + payload, + secret, + ); + await expect( + signatureProvider.verifyHeader({ + payload, + secret, + sigHeader: `t=${value},v1=${hash}`, + tolerance: 180000, + }), + ).resolves.toBe(true); + }, + ); + + it.each([-180001, 180001])( + 'rejects timestamp offset %s outside tolerance', + async (offset) => { + const now = 1791600000000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const value = String(now + offset); + const hash = await signatureProvider.computeSignature( + value, + payload, + secret, + ); + await expect( + signatureProvider.verifyHeader({ + payload, + secret, + sigHeader: `t=${value},v1=${hash}`, + tolerance: 180000, + }), + ).rejects.toThrow('Timestamp outside the tolerance zone'); + }, + ); + it('returns true when the signature is valid', async () => { const sigHeader = `t=${timestamp}, v1=${signatureHash}`; const options = { payload, sigHeader, secret }; diff --git a/src/common/crypto/signature-provider.ts b/src/common/crypto/signature-provider.ts index 06bec8d29..c27bd612e 100644 --- a/src/common/crypto/signature-provider.ts +++ b/src/common/crypto/signature-provider.ts @@ -30,7 +30,12 @@ export class SignatureProvider { ); } - if (parseInt(timestamp, 10) < Date.now() - tolerance) { + const timestampMs = Number(timestamp); + if (!/^\d+$/.test(timestamp) || !Number.isSafeInteger(timestampMs)) { + throw new SignatureVerificationException('Invalid timestamp'); + } + + if (Math.abs(Date.now() - timestampMs) > tolerance) { throw new SignatureVerificationException( 'Timestamp outside the tolerance zone', ); diff --git a/src/webhooks/webhooks.spec.ts b/src/webhooks/webhooks.spec.ts index 697975d26..2fdde9f48 100644 --- a/src/webhooks/webhooks.spec.ts +++ b/src/webhooks/webhooks.spec.ts @@ -28,7 +28,7 @@ describe('Webhook signatures', () => { beforeEach(() => { payload = mockWebhook; secret = 'secret'; - timestamp = Date.now() * 1000; + timestamp = Date.now(); unhashedString = `${timestamp}.${JSON.stringify(payload)}`; signatureHash = crypto .createHmac('sha256', secret)