diff --git a/src/common/crypto/signature-provider.spec.ts b/src/common/crypto/signature-provider.spec.ts index 7244dbe43..feff01e96 100644 --- a/src/common/crypto/signature-provider.spec.ts +++ b/src/common/crypto/signature-provider.spec.ts @@ -56,6 +56,55 @@ describe('SignatureProvider', () => { }); }); + describe('replay protection', () => { + it('rejects a timestamp that is too old', async () => { + const oldTimestamp = (Date.now() - 300000) * 1000; // 5 minutes ago in microseconds + const unhashedString = `${oldTimestamp}.${JSON.stringify(payload)}`; + const oldSignatureHash = crypto + .createHmac('sha256', secret) + .update(unhashedString) + .digest() + .toString('hex'); + const sigHeader = `t=${oldTimestamp}, v1=${oldSignatureHash}`; + const options = { payload, sigHeader, secret, tolerance: 180000 }; + + await expect(signatureProvider.verifyHeader(options)).rejects.toThrow( + 'Timestamp outside the tolerance zone', + ); + }); + + it('rejects a future-dated timestamp', async () => { + const futureTimestamp = (Date.now() + 300000) * 1000; // 5 minutes in the future in microseconds + const unhashedString = `${futureTimestamp}.${JSON.stringify(payload)}`; + const futureSignatureHash = crypto + .createHmac('sha256', secret) + .update(unhashedString) + .digest() + .toString('hex'); + const sigHeader = `t=${futureTimestamp}, v1=${futureSignatureHash}`; + const options = { payload, sigHeader, secret, tolerance: 180000 }; + + await expect(signatureProvider.verifyHeader(options)).rejects.toThrow( + 'Timestamp outside the tolerance zone', + ); + }); + + it('accepts a timestamp within tolerance', async () => { + const recentTimestamp = (Date.now() - 60000) * 1000; // 1 minute ago in microseconds + const unhashedString = `${recentTimestamp}.${JSON.stringify(payload)}`; + const recentSignatureHash = crypto + .createHmac('sha256', secret) + .update(unhashedString) + .digest() + .toString('hex'); + const sigHeader = `t=${recentTimestamp}, v1=${recentSignatureHash}`; + const options = { payload, sigHeader, secret, tolerance: 180000 }; + + const result = await signatureProvider.verifyHeader(options); + expect(result).toBeTruthy(); + }); + }); + describe('when in an environment that supports SubtleCrypto', () => { it('automatically uses the subtle crypto library', () => { // tslint:disable-next-line diff --git a/src/common/crypto/signature-provider.ts b/src/common/crypto/signature-provider.ts index 7168d0db7..40faedeb5 100644 --- a/src/common/crypto/signature-provider.ts +++ b/src/common/crypto/signature-provider.ts @@ -29,7 +29,17 @@ export class SignatureProvider { ); } - if (parseInt(timestamp, 10) < Date.now() - tolerance) { + // WorkOS emits microsecond timestamps; convert to milliseconds for comparison + const timestampMs = Math.floor(parseInt(timestamp, 10) / 1000); + const now = Date.now(); + + if (timestampMs < now - tolerance) { + throw new SignatureVerificationException( + 'Timestamp outside the tolerance zone', + ); + } + + if (timestampMs > now + tolerance) { throw new SignatureVerificationException( 'Timestamp outside the tolerance zone', ); diff --git a/src/webhooks/webhooks.spec.ts b/src/webhooks/webhooks.spec.ts index 2ce5b9efa..85436034f 100644 --- a/src/webhooks/webhooks.spec.ts +++ b/src/webhooks/webhooks.spec.ts @@ -161,6 +161,24 @@ describe('Webhooks', () => { ); }); }); + + describe('with a replayed old webhook', () => { + it('rejects a valid signature whose timestamp exceeds the tolerance', async () => { + const oldTimestamp = (Date.now() - 300000) * 1000; // 5 minutes ago in microseconds + const oldUnhashedString = `${oldTimestamp}.${JSON.stringify(payload)}`; + const oldSignatureHash = crypto + .createHmac('sha256', secret) + .update(oldUnhashedString) + .digest() + .toString('hex'); + const sigHeader = `t=${oldTimestamp}, v1=${oldSignatureHash}`; + const options = { payload, sigHeader, secret }; + + await expect(workos.webhooks.constructEvent(options)).rejects.toThrow( + SignatureVerificationException, + ); + }); + }); }); describe('verifyHeader', () => {