From 9a066eb8902181de45f31f0e96f689daa5bb7481 Mon Sep 17 00:00:00 2001 From: Adithya Date: Sat, 17 Oct 2026 15:25:12 +0530 Subject: [PATCH] fix(api): reject non-hex webhook signatures before comparison (#1253) --- apps/api/src/webhooks/webhook.types.spec.ts | 37 +++++++++++++++++++++ apps/api/src/webhooks/webhook.types.ts | 5 +++ 2 files changed, 42 insertions(+) create mode 100644 apps/api/src/webhooks/webhook.types.spec.ts diff --git a/apps/api/src/webhooks/webhook.types.spec.ts b/apps/api/src/webhooks/webhook.types.spec.ts new file mode 100644 index 00000000..26575e15 --- /dev/null +++ b/apps/api/src/webhooks/webhook.types.spec.ts @@ -0,0 +1,37 @@ +import { createHmac } from 'crypto'; +import { verifyWebhookSignature } from './webhook.types'; + +describe('verifyWebhookSignature', () => { + const secret = 'whsec_test'; + const body = JSON.stringify({ event: 'swap' }); + const valid = createHmac('sha256', secret).update(body).digest('hex'); + + it('accepts a correct HMAC-SHA256 hex digest', () => { + expect(verifyWebhookSignature(body, valid, secret)).toBe(true); + }); + + it('rejects a well-formed but wrong 64-character signature', () => { + expect(verifyWebhookSignature(body, 'a'.repeat(64), secret)).toBe(false); + }); + + it('rejects a valid digest followed by extra characters', () => { + expect(verifyWebhookSignature(body, `${valid}zz`, secret)).toBe(false); + expect(verifyWebhookSignature(body, `${valid}ab`, secret)).toBe(false); + }); + + it('rejects a valid prefix padded with non-hex characters to 64 characters', () => { + const padded = `${valid.slice(0, 62)}zz`; + expect(padded).toHaveLength(64); + expect(verifyWebhookSignature(body, padded, secret)).toBe(false); + }); + + it('rejects odd-length and non-hex signatures', () => { + expect(verifyWebhookSignature(body, valid.slice(0, 63), secret)).toBe(false); + expect(verifyWebhookSignature(body, 'not-a-signature', secret)).toBe(false); + }); + + it('rejects an empty signature or secret', () => { + expect(verifyWebhookSignature(body, '', secret)).toBe(false); + expect(verifyWebhookSignature(body, valid, '')).toBe(false); + }); +}); diff --git a/apps/api/src/webhooks/webhook.types.ts b/apps/api/src/webhooks/webhook.types.ts index 8aa1aa9a..a394a07c 100644 --- a/apps/api/src/webhooks/webhook.types.ts +++ b/apps/api/src/webhooks/webhook.types.ts @@ -1,5 +1,9 @@ import { createHmac, timingSafeEqual } from 'crypto'; +// Buffer.from(x, 'hex') silently stops at the first invalid character, so the +// format has to be checked before decoding. +const SHA256_HEX_PATTERN = /^[0-9a-fA-F]{64}$/; + /** * Verify that a received webhook payload matches the HMAC-SHA256 signature * sent in the `X-Swyft-Signature` header. Returns false for any invalid input. @@ -10,6 +14,7 @@ export function verifyWebhookSignature( secret: string, ): boolean { if (!signature || !secret) return false; + if (!SHA256_HEX_PATTERN.test(signature)) return false; try { const expected = createHmac('sha256', secret).update(body).digest('hex'); const sigBuf = Buffer.from(signature, 'hex');