From d52afd4e63063baf590ca17ee4dd3e4e3f8c195c Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Mon, 7 Sep 2026 19:33:42 +0200 Subject: [PATCH] feat: add built-in webhook signature verification --- README.md | 36 ++++++++++++ src/index.ts | 1 + src/utils/errors.ts | 2 + src/webhook.spec.ts | 132 ++++++++++++++++++++++++++++++++++++++++++++ src/webhook.ts | 94 +++++++++++++++++++++++++++++++ 5 files changed, 265 insertions(+) create mode 100644 src/webhook.spec.ts create mode 100644 src/webhook.ts diff --git a/README.md b/README.md index 707e50d..8a61fa3 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,42 @@ await email.ping(); await api.ping(); ``` +### Webhook Verification + +Use `Webhook` to verify incoming webhooks. Use the webhook signing secret, not an API token. +Pass the raw request body as a string or `Buffer`. Do not parse or change the body before verification. + +```typescript +import { Webhook, WebhookVerificationError } from 'lettermint'; + +const webhook = new Webhook(process.env.LETTERMINT_WEBHOOK_SECRET!); + +try { + const payload = webhook.verifyHeaders(request.headers, rawBody); + // Process the verified payload here. +} catch (error) { + if (error instanceof WebhookVerificationError) { + // Reject the request. Do not process its payload. + } else { + throw error; + } +} +``` + +`verifyHeaders(headers, rawBody)` accepts Node.js request headers. It requires +`X-Lettermint-Signature` and `X-Lettermint-Delivery`. Header names are case-insensitive. +The delivery timestamp must match the timestamp in the signature. + +You can also call `webhook.verify(rawBody, signatureHeader, deliveryTimestamp?)` directly. +Both methods check HMAC-SHA256 signatures with a constant-time comparison and return +the decoded JSON as `unknown`. Check the payload structure before use. + +The default timestamp tolerance is 300 seconds in either direction. To change it, +use `new Webhook(secret, { tolerance: 60 })`. The tolerance must be a non-negative +integer in seconds. A value of `0` only accepts the current second; it does not +disable the timestamp check. A valid signature does not prevent repeat delivery +within this period. Track processed events if you must prevent duplicate work. + ## API Reference ### Lettermint Class diff --git a/src/index.ts b/src/index.ts index d5899a7..543c35f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,4 @@ export * from './endpoints/api'; export * from './types'; export * from './utils/errors'; export * from './lettermint'; +export * from './webhook'; diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 8b1c52b..33297c2 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -32,3 +32,5 @@ export class ClientError extends HttpRequestError { super(message, 400, responseBody); } } + +export class WebhookVerificationError extends LettermintError {} diff --git a/src/webhook.spec.ts b/src/webhook.spec.ts new file mode 100644 index 0000000..31c9ccf --- /dev/null +++ b/src/webhook.spec.ts @@ -0,0 +1,132 @@ +import { createHmac } from 'node:crypto'; +import { LettermintError, Webhook, WebhookVerificationError } from './index'; + +const now = 1700000000; +const secret = 'test-webhook-secret'; +const body = '{"event":"message.delivered","data":{"subject":"Hello 🌍"}}'; +const sign = (payload: string | Buffer = body, timestamp = now, key = secret) => + `t=${timestamp},v1=${createHmac('sha256', key).update(`${timestamp}.`).update(payload).digest('hex')}`; + +describe('Webhook', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(now * 1000); + }); + afterEach(() => jest.restoreAllMocks()); + + it('exports the verifier and SDK error type', () => { + expect(new WebhookVerificationError('test')).toBeInstanceOf(LettermintError); + expect(new Webhook(secret).verify(body, sign())).toEqual(JSON.parse(body)); + }); + + it('verifies exact UTF-8 bytes from a Buffer', () => { + const raw = Buffer.from(` ${body}\n`); + expect(new Webhook(secret).verify(raw, sign(raw))).toEqual(JSON.parse(body)); + expect(() => new Webhook(secret).verify(body, sign(raw))).toThrow(WebhookVerificationError); + }); + + it.each([-300, 0, 300])('accepts timestamps within tolerance: %i', (offset) => { + expect(new Webhook(secret).verify(body, sign(body, now + offset))).toEqual(JSON.parse(body)); + }); + + it.each([-301, 301])('rejects timestamps outside tolerance: %i', (offset) => { + expect(() => new Webhook(secret).verify(body, sign(body, now + offset))).toThrow( + 'outside the allowed range' + ); + }); + + it('uses a custom tolerance and keeps the check enabled at zero', () => { + expect(() => new Webhook(secret, { tolerance: 60 }).verify(body, sign(body, now - 61))).toThrow( + WebhookVerificationError + ); + const webhook = new Webhook(secret, { tolerance: 0 }); + expect(webhook.verify(body, sign())).toEqual(JSON.parse(body)); + expect(() => webhook.verify(body, sign(body, now - 1))).toThrow(WebhookVerificationError); + }); + + it.each([-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid tolerance: %s', + (tolerance) => { + expect(() => new Webhook(secret, { tolerance })).toThrow(WebhookVerificationError); + } + ); + + it('rejects an empty secret', () => { + expect(() => new Webhook('')).toThrow(WebhookVerificationError); + }); + + it('rejects a changed payload or wrong secret', () => { + expect(() => new Webhook(secret).verify('{}', sign())).toThrow('does not match'); + expect(() => new Webhook('wrong').verify(body, sign())).toThrow('does not match'); + }); + + it.each([ + '', + 'v1=abc', + `t=${now}`, + `t=${now},v1=abc`, + `t=${now},v1=${'g'.repeat(64)}`, + `t=${now},v1=${'a'.repeat(63)}`, + `t=${now},v1=${'a'.repeat(65)}`, + `t=${now},${sign()}`, + sign().replace(`t=${now}`, 't=NaN'), + sign().replace(`t=${now}`, 't=1e9'), + sign().replace(`t=${now}`, 't=-1'), + sign().replace(`t=${now}`, 't=9007199254740992'), + sign().replace(`t=${now}`, `t=${now}=extra`), + `${sign()}=extra`, + ])('rejects invalid signature headers: %s', (signature) => { + expect(() => new Webhook(secret).verify(body, signature)).toThrow(WebhookVerificationError); + }); + + it('accepts any matching v1 signature and ignores unsupported versions', () => { + const signature = `v2=ignored, v1=${'0'.repeat(64)}, ${sign()}, v1=malformed`; + expect(new Webhook(secret).verify(body, signature)).toEqual(JSON.parse(body)); + }); + + it('rejects empty bodies and invalid signed JSON', () => { + expect(() => new Webhook(secret).verify('', sign(''))).toThrow(WebhookVerificationError); + expect(() => new Webhook(secret).verify('invalid', sign('invalid'))).toThrow('not valid JSON'); + expect(() => new Webhook(secret).verify('invalid', sign())).toThrow('does not match'); + }); + + it('checks the optional delivery timestamp', () => { + const webhook = new Webhook(secret); + expect(webhook.verify(body, sign(), now)).toEqual(JSON.parse(body)); + for (const timestamp of [now + 1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => webhook.verify(body, sign(), timestamp)).toThrow('do not match'); + } + }); + + it('accepts Node.js headers with case-insensitive names', () => { + expect( + new Webhook(secret).verifyHeaders( + { + 'X-Lettermint-Signature': sign(), + 'x-lettermint-delivery': String(now), + host: 'localhost', + }, + Buffer.from(body) + ) + ).toEqual(JSON.parse(body)); + }); + + it.each([ + {}, + { 'x-lettermint-signature': sign() }, + { 'x-lettermint-delivery': String(now) }, + { 'x-lettermint-signature': [sign()], 'x-lettermint-delivery': String(now) }, + { 'x-lettermint-signature': sign(), 'x-lettermint-delivery': '' }, + { 'x-lettermint-signature': sign(), 'x-lettermint-delivery': `${now}junk` }, + { 'x-lettermint-signature': sign(), 'x-lettermint-delivery': String(now + 1) }, + { 'x-lettermint-signature': sign(), 'x-lettermint-delivery': '9007199254740992' }, + { + 'x-lettermint-signature': sign(), + 'X-Lettermint-Signature': sign(), + 'x-lettermint-delivery': String(now), + }, + ])('rejects missing, ambiguous, or invalid headers: %j', (headers) => { + expect(() => new Webhook(secret).verifyHeaders(headers, body)).toThrow( + WebhookVerificationError + ); + }); +}); diff --git a/src/webhook.ts b/src/webhook.ts new file mode 100644 index 0000000..d053a39 --- /dev/null +++ b/src/webhook.ts @@ -0,0 +1,94 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { WebhookVerificationError } from './utils/errors'; + +export interface WebhookOptions { + /** Maximum timestamp difference in seconds. The default is 300. */ + tolerance?: number; +} + +export type WebhookHeaders = Record; + +export class Webhook { + private readonly tolerance: number; + + constructor( + private readonly secret: string, + options: WebhookOptions = {} + ) { + if (typeof secret !== 'string' || secret.length === 0) { + throw new WebhookVerificationError('The webhook secret is required.'); + } + this.tolerance = options.tolerance ?? 300; + if (!Number.isSafeInteger(this.tolerance) || this.tolerance < 0) { + throw new WebhookVerificationError('The tolerance must be a non-negative integer.'); + } + } + + /** Verify the raw request body before JSON decoding. */ + public verify(payload: string | Buffer, signature: string, timestamp?: number): unknown { + if ((typeof payload !== 'string' && !Buffer.isBuffer(payload)) || payload.length === 0) { + throw new WebhookVerificationError('The raw request body is required.'); + } + if (typeof signature !== 'string' || signature.length === 0) { + throw new WebhookVerificationError('The signature header is required.'); + } + + let signedTimestamp: string | undefined; + const hashes: Buffer[] = []; + for (const part of signature.split(',')) { + const entry = part.trim(); + const separator = entry.indexOf('='); + if (separator === -1) continue; + const key = entry.slice(0, separator); + const value = entry.slice(separator + 1); + if (key === 't') { + if (signedTimestamp !== undefined || !/^\d+$/.test(value ?? '')) { + throw new WebhookVerificationError('The signature timestamp is invalid.'); + } + signedTimestamp = value; + } else if (key === 'v1' && /^[a-fA-F0-9]{64}$/.test(value ?? '')) { + hashes.push(Buffer.from(value, 'hex')); + } + } + const seconds = Number(signedTimestamp); + if (signedTimestamp === undefined || !Number.isSafeInteger(seconds) || hashes.length === 0) { + throw new WebhookVerificationError('The signature header is invalid.'); + } + if (timestamp !== undefined && (!Number.isSafeInteger(timestamp) || timestamp !== seconds)) { + throw new WebhookVerificationError('The signature and delivery timestamps do not match.'); + } + if (Math.abs(Math.floor(Date.now() / 1000) - seconds) > this.tolerance) { + throw new WebhookVerificationError('The signature timestamp is outside the allowed range.'); + } + + const expected = createHmac('sha256', this.secret) + .update(`${signedTimestamp}.`) + .update(payload) + .digest(); + if (!hashes.some((hash) => timingSafeEqual(hash, expected))) { + throw new WebhookVerificationError('The webhook signature does not match.'); + } + try { + return JSON.parse(typeof payload === 'string' ? payload : payload.toString('utf8')); + } catch { + throw new WebhookVerificationError('The webhook payload is not valid JSON.'); + } + } + + /** Verify Node.js request headers and the raw request body. Header names are case-insensitive. */ + public verifyHeaders(headers: WebhookHeaders, payload: string | Buffer): unknown { + const readHeader = (name: string): string => { + const matches = Object.entries(headers).filter(([key]) => key.toLowerCase() === name); + if (matches.length !== 1 || typeof matches[0][1] !== 'string') { + throw new WebhookVerificationError(`A single ${name} header is required.`); + } + return matches[0][1]; + }; + const signature = readHeader('x-lettermint-signature'); + const delivery = readHeader('x-lettermint-delivery'); + if (!/^\d+$/.test(delivery)) { + throw new WebhookVerificationError('The delivery timestamp is invalid.'); + } + return this.verify(payload, signature, Number(delivery)); + } +}