diff --git a/docs/error-codes.md b/docs/error-codes.md index cea1a366..0f27a942 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -72,6 +72,8 @@ This section is generated from `docs/error-codes.yaml`. Run `npm run error-codes | `WEBHOOK_TIMESTAMP_OUT_OF_WINDOW` | Webhooks | | `MALFORMED_WEBHOOK_SIGNATURE` | Webhooks | | `INVALID_WEBHOOK_SIGNATURE` | Webhooks | +| `MALFORMED_WEBHOOK_NONCE` | Webhooks | +| `WEBHOOK_NONCE_REPLAYED` | Webhooks | | `INVALID_DELIVERY_ID` | Webhooks | | `INVALID_RETRY_POLICY` | Webhooks | | `DLQ_ENTRY_NOT_FOUND` | Webhooks | diff --git a/docs/error-codes.yaml b/docs/error-codes.yaml index 31983b94..42a3d54b 100644 --- a/docs/error-codes.yaml +++ b/docs/error-codes.yaml @@ -259,6 +259,14 @@ error_codes: section: Webhooks description: Webhook signature verification failed + - code: MALFORMED_WEBHOOK_NONCE + section: Webhooks + description: Webhook nonce header is malformed + + - code: WEBHOOK_NONCE_REPLAYED + section: Webhooks + description: Webhook nonce has already been used + - code: INVALID_DELIVERY_ID section: Webhooks description: The delivery ID provided for webhook replay is missing or invalid diff --git a/docs/openapi.json b/docs/openapi.json index 484bd4c7..3b342210 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5815,6 +5815,8 @@ "WEBHOOK_TIMESTAMP_OUT_OF_WINDOW", "MALFORMED_WEBHOOK_SIGNATURE", "INVALID_WEBHOOK_SIGNATURE", + "MALFORMED_WEBHOOK_NONCE", + "WEBHOOK_NONCE_REPLAYED", "INVALID_DELIVERY_ID", "INVALID_RETRY_POLICY", "DLQ_ENTRY_NOT_FOUND", diff --git a/docs/webhooks.md b/docs/webhooks.md index 60a0c6c2..3d58ab3e 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -138,8 +138,9 @@ If you provide a `secret` during registration, each webhook delivery includes th | Header | Format | Description | |-----------------------------|---------------------|---------------------------------------| | `X-Request-Id` | string | Correlation ID from the triggering request | -| `X-Callora-Signature-256` | `sha256=` | HMAC-SHA256 of signed payload | -| `X-Callora-Timestamp` | ISO-8601 timestamp | Delivery timestamp for replay defense | +| `X-Callora-Signature-256` | `sha256=` | HMAC-SHA256 of `..` | +| `X-Callora-Timestamp` | ISO-8601 timestamp | Delivery timestamp for skew/replay defense | +| `X-Callora-Nonce` | 16–128 URL-safe chars | Unique request nonce; persisted and rejected on reuse | | `X-Callora-Event` | string | Event type being delivered | | `X-Callora-Delivery` | UUID | Unique delivery identifier for idempotency | | `User-Agent` | `Callora-Webhook/1.0` | Identifies Callora as the sender | @@ -147,25 +148,27 @@ If you provide a `secret` during registration, each webhook delivery includes th #### Signed Payload Format -The signed payload combines the timestamp and raw request body: +The signed payload combines the timestamp, nonce, and raw request body: ``` -. +.. ``` -For example, if the timestamp is `2026-05-31T10:00:00.000Z` and body is `{"event":"new_api_call"}`: +For example, if the timestamp is `2026-05-31T10:00:00.000Z`, the nonce is +`nonce-7c9e6679-7425-40de`, and the body is `{"event":"new_api_call"}`: ``` -2026-05-31T10:00:00.000Z.{"event":"new_api_call"} +2026-05-31T10:00:00.000Z.nonce-7c9e6679-7425-40de.{"event":"new_api_call"} ``` #### Verification Steps -1. **Extract headers** — Get `X-Callora-Signature-256` and `X-Callora-Timestamp` -2. **Reconstruct payload** — Combine `.` -3. **Compute expected signature** — HMAC-SHA256 with your secret -4. **Timing-safe comparison** — Compare using constant-time method -5. **Check timestamp** — Reject if outside 5-minute tolerance window (replay protection) +1. **Extract headers** — Get `X-Callora-Signature-256`, `X-Callora-Timestamp`, and `X-Callora-Nonce` +2. **Reconstruct payload** — Combine `..` +3. **Compute expected signature** — HMAC-SHA256 with the current secret and, during rotation, the previous secret still inside the grace window +4. **Timing-safe comparison** — Compare every active secret using constant-time equality. Failures never identify which key matched. +5. **Check timestamp** — Reject if outside 5-minute tolerance window (clock skew / replay) +6. **Persist nonce** — Reject reused nonces within the same window ### Signing Secret Rotation diff --git a/src/errors/codes.ts b/src/errors/codes.ts index b2c33c77..3a76b49a 100644 --- a/src/errors/codes.ts +++ b/src/errors/codes.ts @@ -189,6 +189,12 @@ export const ErrorCode = { /** Webhook signature verification failed */ INVALID_WEBHOOK_SIGNATURE: "INVALID_WEBHOOK_SIGNATURE", + /** Webhook nonce header is malformed */ + MALFORMED_WEBHOOK_NONCE: "MALFORMED_WEBHOOK_NONCE", + + /** Webhook nonce has already been used */ + WEBHOOK_NONCE_REPLAYED: "WEBHOOK_NONCE_REPLAYED", + /** The delivery ID provided for webhook replay is missing or invalid */ INVALID_DELIVERY_ID: "INVALID_DELIVERY_ID", diff --git a/src/errors/errorCatalog.ts b/src/errors/errorCatalog.ts index 9e10f215..0cb8375c 100644 --- a/src/errors/errorCatalog.ts +++ b/src/errors/errorCatalog.ts @@ -82,6 +82,8 @@ export const ErrorCode = { WEBHOOK_TIMESTAMP_OUT_OF_WINDOW: "WEBHOOK_TIMESTAMP_OUT_OF_WINDOW", MALFORMED_WEBHOOK_SIGNATURE: "MALFORMED_WEBHOOK_SIGNATURE", INVALID_WEBHOOK_SIGNATURE: "INVALID_WEBHOOK_SIGNATURE", + MALFORMED_WEBHOOK_NONCE: "MALFORMED_WEBHOOK_NONCE", + WEBHOOK_NONCE_REPLAYED: "WEBHOOK_NONCE_REPLAYED", INVALID_RETRY_POLICY: "INVALID_RETRY_POLICY", // IP allowlist diff --git a/src/openapi.yaml b/src/openapi.yaml index 811bdc5e..f8810a18 100644 --- a/src/openapi.yaml +++ b/src/openapi.yaml @@ -1540,10 +1540,13 @@ paths: description: > Receives a signed webhook event payload from an external system. The request must include an HMAC-SHA256 signature in the - `X-Callora-Signature-256` header and a Unix timestamp in the - `X-Callora-Timestamp` header. The signature is verified against all - active secrets (current and grace-period previous) before the payload - is processed. + `X-Callora-Signature-256` header, an ISO-8601 timestamp in + `X-Callora-Timestamp`, and a unique nonce in `X-Callora-Nonce`. + The signed payload is `..`. The signature + is verified against all active secrets (current and grace-period + previous) using constant-time comparison. Reused nonces and timestamps + outside the tolerance window are rejected. Failure responses never + identify which key matched. parameters: - name: developerId in: path @@ -1560,8 +1563,10 @@ paths: in: header required: true description: > - HMAC-SHA256 signature of the raw request body, prefixed with - `sha256=`. Computed as `sha256=`. + HMAC-SHA256 of `..`, prefixed with + `sha256=`. Compared in constant time against the current secret + and any previous secret still inside the rotation grace window. + Failure responses never identify which key matched. schema: type: string examples: @@ -1571,13 +1576,28 @@ paths: - name: X-Callora-Timestamp in: header required: true - description: Unix timestamp (seconds) of when the event was sent. + description: ISO-8601 timestamp of when the event was sent. Rejected when outside the configured skew window. schema: type: string examples: ts: summary: Example timestamp header - value: "1722074400" + value: "2026-07-27T09:30:00.000Z" + - name: X-Callora-Nonce + in: header + required: true + description: > + Unique request nonce (16–128 URL-safe characters). Bound into the + HMAC and persisted for the timestamp window so replays are rejected. + schema: + type: string + minLength: 16 + maxLength: 128 + pattern: "^[A-Za-z0-9._-]{16,128}$" + examples: + nonce: + summary: Example nonce header + value: "nonce-7c9e6679-7425-40de" requestBody: required: true content: @@ -1657,7 +1677,7 @@ paths: requestId: req-webhook-deliver-400-sig timestamp: "2026-07-27T09:31:00.000Z" "401": - description: HMAC signature verification failed. + description: HMAC signature verification failed, timestamp outside the skew window, or nonce replayed. content: application/json: schema: @@ -1672,6 +1692,15 @@ paths: message: Webhook signature verification failed requestId: req-webhook-deliver-401-invalid timestamp: "2026-07-27T09:32:00.000Z" + nonceReplayed: + summary: Nonce was already consumed (replay) + value: + success: false + error: + code: UNAUTHORIZED + message: Webhook signature verification failed + requestId: req-webhook-deliver-401-replay + timestamp: "2026-07-27T09:32:30.000Z" "404": description: No webhook registered for this developer. content: diff --git a/src/routes/webhooks.openapi.test.ts b/src/routes/webhooks.openapi.test.ts index 7a4887b2..e61a6b59 100644 --- a/src/routes/webhooks.openapi.test.ts +++ b/src/routes/webhooks.openapi.test.ts @@ -228,11 +228,12 @@ describe('src/openapi.yaml — POST deliver examples', () => { expect(content).toContain('$ref: "#/components/schemas/WebhookDeliveryResponse"'); }); - test('documents signature and timestamp header parameters', () => { + test('documents signature, timestamp, and nonce header parameters', () => { const content = readOpenApiYaml(); expect(content).toContain('X-Callora-Signature-256'); expect(content).toContain('X-Callora-Timestamp'); + expect(content).toContain('X-Callora-Nonce'); }); test('documents three delivery request examples covering all supported event types', () => { diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index bcd368f2..437d47f7 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -7,6 +7,7 @@ import { WebhookEventType, type RetryPolicy } from '../webhooks/webhook.types.js import { captureRawBody, verifyWebhookSignature, + parseCapturedJson, } from '../webhooks/webhook.signature.js'; import { AppError, BadRequestError, NotFoundError } from '../errors/index.js'; import { createRestRateLimitMiddleware } from '../middleware/restRateLimit.js'; @@ -288,7 +289,7 @@ router.post( next(); }, verifyWebhookSignature, - express.json(), + parseCapturedJson, (req: Request, res: Response) => { return res.status(200).json({ message: 'Webhook delivery accepted.', body: req.body }); } diff --git a/src/webhooks/webhook.deliver.test.ts b/src/webhooks/webhook.deliver.test.ts new file mode 100644 index 00000000..c1434ae8 --- /dev/null +++ b/src/webhooks/webhook.deliver.test.ts @@ -0,0 +1,196 @@ +/** + * HTTP-level coverage for inbound webhook delivery: + * rotation window, clock skew, nonce replay, malformed headers, and deletion. + */ + +import request from 'supertest'; +import express from 'express'; +import { + computeSignature, + SIGNATURE_HEADER, + TIMESTAMP_HEADER, + NONCE_HEADER, + SIGNATURE_TOLERANCE_MS, +} from './webhook.signature.js'; +import { WebhookStore } from './webhook.store.js'; +import { requestIdMiddleware } from '../middleware/requestId.js'; +import { errorHandler } from '../middleware/errorHandler.js'; + +jest.mock('../db.js', () => ({ + writeQuery: jest.fn().mockResolvedValue({ rows: [] }), +})); + +jest.mock('../logger.js', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + audit: jest.fn(), + }, + runWithRequestContext: (_ctx: unknown, callback: () => T): T => callback(), +})); + +import { createWebhooksRouter } from '../routes/webhooks.js'; + +function buildApp() { + const app = express(); + app.use(requestIdMiddleware); + app.use('/api/webhooks', createWebhooksRouter()); + app.use(errorHandler); + return app; +} + +function nonce(label: string): string { + return `nonce-${label}-0123456789ab`; +} + +function signedHeaders(secret: string, body: string, opts: { ts?: string; nonce?: string } = {}) { + const ts = opts.ts ?? new Date().toISOString(); + const n = opts.nonce ?? nonce('ok'); + return { + [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: n, + [SIGNATURE_HEADER]: `sha256=${computeSignature(secret, ts, body, n)}`, + }; +} + +const DELIVER_BODY = JSON.stringify({ + event: 'new_api_call', + timestamp: '2026-07-27T09:30:00.000Z', + developerId: 'dev-deliver', + data: { apiId: 'api-1' }, +}); + +describe('POST /api/webhooks/deliver/:developerId — rotation, skew, replay, deletion', () => { + let app: express.Express; + + beforeEach(() => { + app = buildApp(); + WebhookStore.clear(); + WebhookStore.register({ + developerId: 'dev-deliver', + url: 'https://example.com/webhook', + events: ['new_api_call'], + secret_current: 'current-secret', + createdAt: new Date(), + }); + }); + + async function deliver( + developerId: string, + headers: Record, + body: string = DELIVER_BODY, + ) { + return request(app) + .post(`/api/webhooks/deliver/${developerId}`) + .set(headers) + .set('Content-Type', 'application/json') + .send(body); + } + + it('accepts a delivery signed with the current key', async () => { + const res = await deliver('dev-deliver', signedHeaders('current-secret', DELIVER_BODY, { nonce: nonce('cur') })); + expect(res.status).toBe(200); + expect(res.body.message).toBe('Webhook delivery accepted.'); + }); + + it('accepts both current and previous keys only inside the rotation window', async () => { + const expiresAt = new Date(Date.now() + 60_000); + WebhookStore.rotateSecret('dev-deliver', 'rotated-secret', expiresAt); + const stored = WebhookStore.get('dev-deliver')!; + + const previous = await deliver( + 'dev-deliver', + signedHeaders('current-secret', DELIVER_BODY, { nonce: nonce('prev') }), + ); + const current = await deliver( + 'dev-deliver', + signedHeaders('rotated-secret', DELIVER_BODY, { nonce: nonce('new') }), + ); + expect(previous.status).toBe(200); + expect(current.status).toBe(200); + + stored.previous_expires_at = new Date(Date.now() - 1); + const expired = await deliver( + 'dev-deliver', + signedHeaders('current-secret', DELIVER_BODY, { nonce: nonce('exp') }), + ); + expect(expired.status).toBe(401); + expect(expired.body.error?.code ?? expired.body.code).toBe('INVALID_WEBHOOK_SIGNATURE'); + expect(JSON.stringify(expired.body)).not.toContain('current-secret'); + expect(JSON.stringify(expired.body)).not.toContain('rotated-secret'); + expect(JSON.stringify(expired.body)).not.toMatch(/previous|matched key/i); + }); + + it('rejects timestamps outside the skew window', async () => { + const stale = new Date(Date.now() - SIGNATURE_TOLERANCE_MS - 2_000).toISOString(); + const future = new Date(Date.now() + SIGNATURE_TOLERANCE_MS + 2_000).toISOString(); + + const oldRes = await deliver( + 'dev-deliver', + signedHeaders('current-secret', DELIVER_BODY, { ts: stale, nonce: nonce('stale') }), + ); + const futureRes = await deliver( + 'dev-deliver', + signedHeaders('current-secret', DELIVER_BODY, { ts: future, nonce: nonce('future') }), + ); + + expect(oldRes.status).toBe(401); + expect(oldRes.body.error?.code ?? oldRes.body.code).toBe('WEBHOOK_TIMESTAMP_OUT_OF_WINDOW'); + expect(futureRes.status).toBe(401); + expect(futureRes.body.error?.code ?? futureRes.body.code).toBe('WEBHOOK_TIMESTAMP_OUT_OF_WINDOW'); + }); + + it('rejects a reused nonce as a replay', async () => { + const headers = signedHeaders('current-secret', DELIVER_BODY, { nonce: nonce('replay') }); + const first = await deliver('dev-deliver', headers); + const second = await deliver('dev-deliver', headers); + + expect(first.status).toBe(200); + expect(second.status).toBe(401); + expect(second.body.error?.code ?? second.body.code).toBe('WEBHOOK_NONCE_REPLAYED'); + expect(second.body.error?.message ?? second.body.message).toBe('Webhook signature verification failed.'); + }); + + it('rejects malformed signature and nonce headers', async () => { + const ts = new Date().toISOString(); + const missingPrefix = await deliver('dev-deliver', { + [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: nonce('malformed'), + [SIGNATURE_HEADER]: 'not-a-signature', + }); + expect(missingPrefix.status).toBe(400); + expect(missingPrefix.body.error?.code ?? missingPrefix.body.code).toBe('MALFORMED_WEBHOOK_SIGNATURE'); + + const badNonce = await deliver('dev-deliver', { + [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: 'bad nonce!!', + [SIGNATURE_HEADER]: `sha256=${computeSignature('current-secret', ts, DELIVER_BODY, 'bad nonce!!')}`, + }); + expect(badNonce.status).toBe(400); + expect(badNonce.body.error?.code ?? badNonce.body.code).toBe('MALFORMED_WEBHOOK_NONCE'); + + const missing = await deliver('dev-deliver', { + [TIMESTAMP_HEADER]: ts, + [SIGNATURE_HEADER]: 'sha256=abcd', + }); + expect(missing.status).toBe(401); + expect(missing.body.error?.code ?? missing.body.code).toBe('MISSING_WEBHOOK_SIGNATURE_HEADERS'); + }); + + it('rejects deliveries after the webhook is deleted', async () => { + const headers = signedHeaders('current-secret', DELIVER_BODY, { nonce: nonce('del') }); + const before = await deliver('dev-deliver', headers); + expect(before.status).toBe(200); + + await request(app).delete('/api/webhooks/dev-deliver').expect(200); + + const after = await deliver( + 'dev-deliver', + signedHeaders('current-secret', DELIVER_BODY, { nonce: nonce('after-del') }), + ); + expect(after.status).toBe(404); + expect(after.body.error?.code ?? after.body.code).toBe('WEBHOOK_NOT_FOUND'); + }); +}); diff --git a/src/webhooks/webhook.nonceStore.test.ts b/src/webhooks/webhook.nonceStore.test.ts new file mode 100644 index 00000000..8c92a38b --- /dev/null +++ b/src/webhooks/webhook.nonceStore.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { WebhookNonceStore } from './webhook.nonceStore.js'; + +describe('WebhookNonceStore', () => { + beforeEach(() => { + WebhookNonceStore.clear(); + }); + + it('consumes a nonce once and rejects reuse', () => { + assert.equal(WebhookNonceStore.consume('dev-1', 'nonce-abcdefghijklmnopqrst', 60_000), true); + assert.equal(WebhookNonceStore.consume('dev-1', 'nonce-abcdefghijklmnopqrst', 60_000), false); + assert.equal(WebhookNonceStore.has('dev-1', 'nonce-abcdefghijklmnopqrst'), true); + }); + + it('scopes nonces per developer', () => { + assert.equal(WebhookNonceStore.consume('dev-a', 'nonce-abcdefghijklmnopqrst', 60_000), true); + assert.equal(WebhookNonceStore.consume('dev-b', 'nonce-abcdefghijklmnopqrst', 60_000), true); + }); + + it('allows reuse after TTL expiry', () => { + const now = 1_000_000; + assert.equal(WebhookNonceStore.consume('dev-1', 'nonce-abcdefghijklmnopqrst', 1_000, now), true); + assert.equal(WebhookNonceStore.consume('dev-1', 'nonce-abcdefghijklmnopqrst', 1_000, now + 500), false); + assert.equal(WebhookNonceStore.consume('dev-1', 'nonce-abcdefghijklmnopqrst', 1_000, now + 1_001), true); + }); + + it('purgeScope deletes only that developer\'s nonces', () => { + WebhookNonceStore.consume('dev-a', 'nonce-abcdefghijklmnopqrst', 60_000); + WebhookNonceStore.consume('dev-b', 'nonce-abcdefghijklmnopqrst', 60_000); + WebhookNonceStore.purgeScope('dev-a'); + assert.equal(WebhookNonceStore.has('dev-a', 'nonce-abcdefghijklmnopqrst'), false); + assert.equal(WebhookNonceStore.has('dev-b', 'nonce-abcdefghijklmnopqrst'), true); + }); + + it('purgeExpired removes stale records', () => { + const now = 5_000; + WebhookNonceStore.consume('dev-1', 'nonce-abcdefghijklmnopqrst', 100, now); + WebhookNonceStore.purgeExpired(now + 101); + assert.equal(WebhookNonceStore.size(), 0); + }); +}); diff --git a/src/webhooks/webhook.nonceStore.ts b/src/webhooks/webhook.nonceStore.ts new file mode 100644 index 00000000..ba0112d4 --- /dev/null +++ b/src/webhooks/webhook.nonceStore.ts @@ -0,0 +1,64 @@ +/** + * In-memory nonce ledger for inbound webhook replay protection. + * + * Entries are scoped per developer so one subscriber cannot poison another's + * nonce space. TTL matches the signature timestamp window: once a timestamp + * would already be rejected as stale, the nonce can be forgotten. + */ + +const SEPARATOR = '\u0000'; + +const used = new Map(); + +function entryKey(scope: string, nonce: string): string { + return `${scope}${SEPARATOR}${nonce}`; +} + +export const WebhookNonceStore = { + /** + * Record a nonce as consumed. Returns false when the nonce is already + * persisted and has not yet expired (replay). + */ + consume(scope: string, nonce: string, ttlMs: number, now: number = Date.now()): boolean { + this.purgeExpired(now); + const key = entryKey(scope, nonce); + const expiresAt = used.get(key); + if (expiresAt !== undefined && expiresAt > now) { + return false; + } + used.set(key, now + ttlMs); + return true; + }, + + has(scope: string, nonce: string, now: number = Date.now()): boolean { + const expiresAt = used.get(entryKey(scope, nonce)); + return expiresAt !== undefined && expiresAt > now; + }, + + /** Drop every nonce belonging to a developer (called on webhook deletion). */ + purgeScope(scope: string): void { + const prefix = `${scope}${SEPARATOR}`; + for (const key of used.keys()) { + if (key.startsWith(prefix)) { + used.delete(key); + } + } + }, + + purgeExpired(now: number = Date.now()): void { + for (const [key, expiresAt] of used) { + if (expiresAt <= now) { + used.delete(key); + } + } + }, + + size(): number { + return used.size; + }, + + /** Test helper. */ + clear(): void { + used.clear(); + }, +}; diff --git a/src/webhooks/webhook.routes.ts b/src/webhooks/webhook.routes.ts index 4266acf8..8da38df5 100644 --- a/src/webhooks/webhook.routes.ts +++ b/src/webhooks/webhook.routes.ts @@ -7,6 +7,7 @@ import { WebhookEventType, type RetryPolicy } from './webhook.types.js'; import { captureRawBody, verifyWebhookSignature, + parseCapturedJson, } from './webhook.signature.js'; import { AppError, BadRequestError, NotFoundError } from '../errors/index.js'; import { createRestRateLimitMiddleware } from '../middleware/restRateLimit.js'; @@ -16,6 +17,13 @@ import { logger } from '../logger.js'; import { validateRetryPolicy } from '../services/webhookRetry.js'; import { createWebhookHealthRouter } from '../routes/webhooks/health.js'; import { securityHeadersMiddleware } from '../middleware/securityHeaders.js'; +import { validate } from '../middleware/validate.js'; +import { + registerWebhookSchema, + webhookDeveloperParamsSchema, + updateWebhookRetryPolicySchema, + webhookDeliveryPayloadSchema, +} from '../validators/webhooks.js'; const router = Router(); @@ -244,7 +252,7 @@ router.post( next(); }, verifyWebhookSignature, - express.json(), + parseCapturedJson, validate({ body: webhookDeliveryPayloadSchema }), (req: Request, res: Response) => { // Payload has been verified — safe to process diff --git a/src/webhooks/webhook.signature.test.ts b/src/webhooks/webhook.signature.test.ts index 8b23ac99..c50d1006 100644 --- a/src/webhooks/webhook.signature.test.ts +++ b/src/webhooks/webhook.signature.test.ts @@ -6,13 +6,17 @@ import type { Request, Response, NextFunction } from 'express'; import { computeSignature, safeCompare, + matchesAnySecret, verifyWebhookSignature, captureRawBody, + parseCapturedJson, SIGNATURE_HEADER, TIMESTAMP_HEADER, + NONCE_HEADER, SIGNATURE_TOLERANCE_MS, } from './webhook.signature.js'; import { WebhookStore } from './webhook.store.js'; +import { WebhookNonceStore } from './webhook.nonceStore.js'; // --------------------------------------------------------------------------- // Helpers @@ -22,28 +26,59 @@ function makeTimestamp(offsetMs = 0): string { return new Date(Date.now() + offsetMs).toISOString(); } +function makeNonce(label = 'test'): string { + return `nonce-${label}-0123456789ab`; +} + /** Minimal Request stub — only the fields our middleware touches. */ function makeReq( overrides: Partial<{ headers: Record; webhookSecret: string; webhookSecrets: string[]; + webhookNonceScope: string; rawBody: Buffer; + params: Record; }> = {} -): Request & { webhookSecret?: string; webhookSecrets?: string[]; rawBody?: Buffer } { +): Request & { + webhookSecret?: string; + webhookSecrets?: string[]; + webhookNonceScope?: string; + rawBody?: Buffer; + params: Record; +} { const emitter = new EventEmitter() as unknown as Request & { webhookSecret?: string; webhookSecrets?: string[]; + webhookNonceScope?: string; rawBody?: Buffer; headers: Record; + params: Record; }; emitter.headers = overrides.headers ?? {}; emitter.webhookSecret = overrides.webhookSecret; emitter.webhookSecrets = overrides.webhookSecrets; + emitter.webhookNonceScope = overrides.webhookNonceScope; emitter.rawBody = overrides.rawBody; + emitter.params = overrides.params ?? { developerId: 'dev-test' }; return emitter; } +function signedHeaders( + secret: string, + body: Buffer | string, + opts: { ts?: string; nonce?: string } = {} +): Record { + const ts = opts.ts ?? makeTimestamp(); + const nonce = opts.nonce ?? makeNonce(); + const sig = computeSignature(secret, ts, body, nonce); + return { + [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: nonce, + [SIGNATURE_HEADER]: `sha256=${sig}`, + }; +} + /** Minimal Response stub that records status + json calls. */ function makeRes(): Response & { _status: number; _body: unknown } { const res = { @@ -75,6 +110,10 @@ function collectNextError( return { nextCalled, error: capturedError }; } +beforeEach(() => { + WebhookNonceStore.clear(); +}); + // --------------------------------------------------------------------------- // computeSignature // --------------------------------------------------------------------------- @@ -120,6 +159,22 @@ test('computeSignature accepts a plain string body', () => { assert.equal(fromString, fromBuffer); }); +test('computeSignature includes nonce in the MAC when provided', () => { + const ts = '2026-01-01T00:00:00.000Z'; + const body = Buffer.from('body'); + const withoutNonce = computeSignature('secret', ts, body); + const withNonce = computeSignature('secret', ts, body, makeNonce()); + assert.notEqual(withoutNonce, withNonce); +}); + +test('computeSignature differs when nonce changes', () => { + const ts = '2026-01-01T00:00:00.000Z'; + const body = Buffer.from('body'); + const a = computeSignature('secret', ts, body, makeNonce('a')); + const b = computeSignature('secret', ts, body, makeNonce('b')); + assert.notEqual(a, b); +}); + // --------------------------------------------------------------------------- // safeCompare // --------------------------------------------------------------------------- @@ -167,7 +222,7 @@ test('verifyWebhookSignature rejects when signature header is missing', () => { const ts = makeTimestamp(); const req = makeReq({ webhookSecret: 'secret', - headers: { [TIMESTAMP_HEADER]: ts }, // no SIGNATURE_HEADER + headers: { [TIMESTAMP_HEADER]: ts, [NONCE_HEADER]: makeNonce() }, rawBody: Buffer.from('{}'), }); const res = makeRes(); @@ -180,7 +235,21 @@ test('verifyWebhookSignature rejects when signature header is missing', () => { test('verifyWebhookSignature rejects when timestamp header is missing', () => { const req = makeReq({ webhookSecret: 'secret', - headers: { [SIGNATURE_HEADER]: 'sha256=abc' }, // no TIMESTAMP_HEADER + headers: { [SIGNATURE_HEADER]: 'sha256=abc', [NONCE_HEADER]: makeNonce() }, + rawBody: Buffer.from('{}'), + }); + const res = makeRes(); + const { nextCalled, error } = collectNextError((next) => verifyWebhookSignature(req, res, next)); + assert.equal(nextCalled, true); + assert.equal((error as { name?: string }).name, 'UnauthorizedError'); + assert.equal((error as { code?: string }).code, 'MISSING_WEBHOOK_SIGNATURE_HEADERS'); +}); + +test('verifyWebhookSignature rejects when nonce header is missing', () => { + const ts = makeTimestamp(); + const req = makeReq({ + webhookSecret: 'secret', + headers: { [TIMESTAMP_HEADER]: ts, [SIGNATURE_HEADER]: 'sha256=abc' }, rawBody: Buffer.from('{}'), }); const res = makeRes(); @@ -195,6 +264,7 @@ test('verifyWebhookSignature rejects a non-ISO timestamp', () => { webhookSecret: 'secret', headers: { [TIMESTAMP_HEADER]: 'not-a-date', + [NONCE_HEADER]: makeNonce(), [SIGNATURE_HEADER]: 'sha256=abc123', }, rawBody: Buffer.from('{}'), @@ -212,6 +282,7 @@ test('verifyWebhookSignature rejects a stale timestamp (too old)', () => { webhookSecret: 'secret', headers: { [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: makeNonce(), [SIGNATURE_HEADER]: 'sha256=deadbeef', }, rawBody: Buffer.from('{}'), @@ -229,6 +300,7 @@ test('verifyWebhookSignature rejects a future timestamp outside tolerance', () = webhookSecret: 'secret', headers: { [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: makeNonce(), [SIGNATURE_HEADER]: 'sha256=deadbeef', }, rawBody: Buffer.from('{}'), @@ -240,12 +312,21 @@ test('verifyWebhookSignature rejects a future timestamp outside tolerance', () = assert.equal((error as { code?: string }).code, 'WEBHOOK_TIMESTAMP_OUT_OF_WINDOW'); }); +test('verifyWebhookSignature accepts a timestamp inside the skew window', (done) => { + const body = Buffer.from('{"event":"new_api_call"}'); + const ts = makeTimestamp(-(SIGNATURE_TOLERANCE_MS - 5_000)); + const headers = signedHeaders('secret', body, { ts }); + const req = makeReq({ webhookSecret: 'secret', headers, rawBody: body }); + verifyWebhookSignature(req, makeRes(), () => { done(); }); +}); + test('verifyWebhookSignature rejects a malformed signature header (no prefix)', () => { const ts = makeTimestamp(); const req = makeReq({ webhookSecret: 'secret', headers: { [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: makeNonce(), [SIGNATURE_HEADER]: 'badhex', // missing sha256= prefix }, rawBody: Buffer.from('{}'), @@ -263,6 +344,7 @@ test('verifyWebhookSignature rejects a wrong prefix (md5=…)', () => { webhookSecret: 'secret', headers: { [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: makeNonce(), [SIGNATURE_HEADER]: 'md5=abc123', }, rawBody: Buffer.from('{}'), @@ -274,21 +356,53 @@ test('verifyWebhookSignature rejects a wrong prefix (md5=…)', () => { assert.equal((error as { code?: string }).code, 'MALFORMED_WEBHOOK_SIGNATURE'); }); +test('verifyWebhookSignature rejects a malformed nonce', () => { + const body = Buffer.from('{}'); + const ts = makeTimestamp(); + const req = makeReq({ + webhookSecret: 'secret', + headers: { + [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: 'short', + [SIGNATURE_HEADER]: `sha256=${computeSignature('secret', ts, body, 'short')}`, + }, + rawBody: body, + }); + const res = makeRes(); + const { nextCalled, error } = collectNextError((next) => verifyWebhookSignature(req, res, next)); + assert.equal(nextCalled, true); + assert.equal((error as { name?: string }).name, 'BadRequestError'); + assert.equal((error as { code?: string }).code, 'MALFORMED_WEBHOOK_NONCE'); +}); + +test('verifyWebhookSignature rejects a nonce with illegal characters', () => { + const body = Buffer.from('{}'); + const ts = makeTimestamp(); + const nonce = 'nonce with spaces!!!!'; + const req = makeReq({ + webhookSecret: 'secret', + headers: { + [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: nonce, + [SIGNATURE_HEADER]: `sha256=${computeSignature('secret', ts, body, nonce)}`, + }, + rawBody: body, + }); + const { error } = collectNextError((next) => verifyWebhookSignature(req, makeRes(), next)); + assert.equal((error as { code?: string }).code, 'MALFORMED_WEBHOOK_NONCE'); +}); + // --------------------------------------------------------------------------- // verifyWebhookSignature — signature mismatch // --------------------------------------------------------------------------- test('verifyWebhookSignature rejects when HMAC does not match', () => { - const ts = makeTimestamp(); const body = Buffer.from('{"event":"new_api_call"}'); - const wrongHex = computeSignature('wrong-secret', ts, body); + const headers = signedHeaders('wrong-secret', body); const req = makeReq({ webhookSecret: 'correct-secret', - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${wrongHex}`, - }, + headers, rawBody: body, }); const res = makeRes(); @@ -296,20 +410,18 @@ test('verifyWebhookSignature rejects when HMAC does not match', () => { assert.equal(nextCalled, true); assert.equal((error as { name?: string }).name, 'UnauthorizedError'); assert.equal((error as { code?: string }).code, 'INVALID_WEBHOOK_SIGNATURE'); + assert.equal((error as { message?: string }).message, 'Webhook signature verification failed.'); + assert.doesNotMatch((error as { message?: string }).message ?? '', /current|previous|key/i); }); test('verifyWebhookSignature rejects when body has been tampered with', () => { - const ts = makeTimestamp(); const originalBody = Buffer.from('{"event":"new_api_call"}'); const tamperedBody = Buffer.from('{"event":"settlement_completed"}'); - const sig = computeSignature('secret', ts, originalBody); + const headers = signedHeaders('secret', originalBody); const req = makeReq({ webhookSecret: 'secret', - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers, rawBody: tamperedBody, }); const res = makeRes(); @@ -319,21 +431,34 @@ test('verifyWebhookSignature rejects when body has been tampered with', () => { assert.equal((error as { code?: string }).code, 'INVALID_WEBHOOK_SIGNATURE'); }); +test('failure response does not reveal which rotation key was tested', () => { + const body = Buffer.from('{"event":"new_api_call"}'); + const headers = signedHeaders('attacker-secret', body); + const req = makeReq({ + webhookSecrets: ['current-secret', 'previous-secret'], + headers, + rawBody: body, + }); + const { error } = collectNextError((next) => verifyWebhookSignature(req, makeRes(), next)); + const serialized = JSON.stringify(error); + assert.equal((error as { code?: string }).code, 'INVALID_WEBHOOK_SIGNATURE'); + assert.equal((error as { message?: string }).message, 'Webhook signature verification failed.'); + assert.equal(serialized.includes('current-secret'), false); + assert.equal(serialized.includes('previous-secret'), false); + assert.equal(serialized.includes('matched'), false); + assert.equal('matchedKey' in (req as object), false); + assert.equal('webhookMatchedSecret' in (req as object), false); +}); + // --------------------------------------------------------------------------- // verifyWebhookSignature — happy path // --------------------------------------------------------------------------- test('verifyWebhookSignature calls next() for a valid signature', (done) => { - const ts = makeTimestamp(); const body = Buffer.from('{"event":"new_api_call"}'); - const sig = computeSignature('my-secret', ts, body); - const req = makeReq({ webhookSecret: 'my-secret', - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers: signedHeaders('my-secret', body), rawBody: body, }); const res = makeRes(); @@ -341,16 +466,10 @@ test('verifyWebhookSignature calls next() for a valid signature', (done) => { }); test('verifyWebhookSignature accepts a signature from the current secret when multiple secrets are configured', (done) => { - const ts = makeTimestamp(); const body = Buffer.from('{"event":"new_api_call"}'); - const sig = computeSignature('current-secret', ts, body); - const req = makeReq({ webhookSecrets: ['current-secret', 'previous-secret'], - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers: signedHeaders('current-secret', body), rawBody: body, }); const res = makeRes(); @@ -358,16 +477,10 @@ test('verifyWebhookSignature accepts a signature from the current secret when mu }); test('verifyWebhookSignature accepts a signature from the unexpired previous secret', (done) => { - const ts = makeTimestamp(); const body = Buffer.from('{"event":"new_api_call"}'); - const sig = computeSignature('previous-secret', ts, body); - const req = makeReq({ webhookSecrets: ['current-secret', 'previous-secret'], - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers: signedHeaders('previous-secret', body), rawBody: body, }); const res = makeRes(); @@ -375,16 +488,10 @@ test('verifyWebhookSignature accepts a signature from the unexpired previous sec }); test('verifyWebhookSignature rejects a previous secret after its grace window is removed', () => { - const ts = makeTimestamp(); const body = Buffer.from('{"event":"new_api_call"}'); - const sig = computeSignature('previous-secret', ts, body); - const req = makeReq({ webhookSecrets: ['current-secret'], - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers: signedHeaders('previous-secret', body), rawBody: body, }); const res = makeRes(); @@ -392,9 +499,10 @@ test('verifyWebhookSignature rejects a previous secret after its grace window is assert.equal(nextCalled, true); assert.equal((error as { name?: string }).name, 'UnauthorizedError'); assert.equal((error as { code?: string }).code, 'INVALID_WEBHOOK_SIGNATURE'); + assert.doesNotMatch((error as { message?: string }).message ?? '', /previous/i); }); -test('WebhookStore.getActiveSecrets excludes the previous secret after previous_expires_at', () => { +test('WebhookStore.getActiveSecrets excludes and deletes the previous secret after previous_expires_at', () => { const config = { developerId: 'dev-expired', url: 'https://example.com/webhook', @@ -413,19 +521,15 @@ test('WebhookStore.getActiveSecrets excludes the previous secret after previous_ WebhookStore.getActiveSecrets(config, new Date('2026-06-25T12:00:01.000Z')), ['current-secret'], ); + assert.equal(config.secret_previous, undefined); + assert.equal(config.previous_expires_at, undefined); }); test('verifyWebhookSignature handles empty rawBody gracefully', (done) => { - const ts = makeTimestamp(); const body = Buffer.alloc(0); - const sig = computeSignature('secret', ts, body); - const req = makeReq({ webhookSecret: 'secret', - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers: signedHeaders('secret', body), rawBody: body, }); const res = makeRes(); @@ -433,25 +537,73 @@ test('verifyWebhookSignature handles empty rawBody gracefully', (done) => { }); test('verifyWebhookSignature falls back to empty buffer when rawBody is undefined', (done) => { - const ts = makeTimestamp(); - const sig = computeSignature('secret', ts, Buffer.alloc(0)); - + const body = Buffer.alloc(0); const req = makeReq({ webhookSecret: 'secret', - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers: signedHeaders('secret', body), // rawBody intentionally not set }); const res = makeRes(); verifyWebhookSignature(req, res, () => { done(); }); }); +test('matchesAnySecret returns true when any configured key matches', () => { + const body = Buffer.from('body'); + const ts = makeTimestamp(); + const nonce = makeNonce(); + const received = computeSignature('previous-secret', ts, body, nonce); + assert.equal( + matchesAnySecret(['current-secret', 'previous-secret', 'stale-secret'], ts, body, received, nonce), + true, + ); + assert.equal( + matchesAnySecret(['current-secret', 'stale-secret'], ts, body, received, nonce), + false, + ); +}); + +test('verifyWebhookSignature rejects a reused nonce as a replay', () => { + const body = Buffer.from('{"event":"new_api_call"}'); + const headers = signedHeaders('secret', body, { nonce: makeNonce('replay') }); + const first = makeReq({ + webhookSecret: 'secret', + headers, + rawBody: body, + params: { developerId: 'dev-replay' }, + }); + const second = makeReq({ + webhookSecret: 'secret', + headers, + rawBody: body, + params: { developerId: 'dev-replay' }, + }); + + const firstPass = collectNextError((next) => verifyWebhookSignature(first, makeRes(), next)); + assert.equal(firstPass.error, undefined); + + const replay = collectNextError((next) => verifyWebhookSignature(second, makeRes(), next)); + assert.equal((replay.error as { name?: string }).name, 'UnauthorizedError'); + assert.equal((replay.error as { code?: string }).code, 'WEBHOOK_NONCE_REPLAYED'); + assert.equal((replay.error as { message?: string }).message, 'Webhook signature verification failed.'); +}); + +test('verifyWebhookSignature does not persist a nonce when the signature is invalid', () => { + const body = Buffer.from('{"event":"new_api_call"}'); + const nonce = makeNonce('unauth'); + const headers = signedHeaders('wrong-secret', body, { nonce }); + const req = makeReq({ + webhookSecret: 'correct-secret', + headers, + rawBody: body, + params: { developerId: 'dev-unauth' }, + }); + collectNextError((next) => verifyWebhookSignature(req, makeRes(), next)); + assert.equal(WebhookNonceStore.has('dev-unauth', nonce), false); +}); + // --------------------------------------------------------------------------- // captureRawBody // --------------------------------------------------------------------------- - test('captureRawBody attaches raw bytes to req.rawBody', (done) => { const req = makeReq() as Request & { rawBody?: Buffer }; const res = makeRes(); @@ -493,3 +645,21 @@ test('captureRawBody forwards stream errors to next', (done) => { req.emit('error', boom); }); + +test('parseCapturedJson populates req.body from rawBody', (done) => { + const req = makeReq({ + rawBody: Buffer.from('{"event":"new_api_call"}'), + }) as Request & { rawBody?: Buffer; body?: unknown }; + parseCapturedJson(req, makeRes(), () => { + assert.deepEqual(req.body, { event: 'new_api_call' }); + done(); + }); +}); + +test('parseCapturedJson rejects invalid JSON', () => { + const req = makeReq({ + rawBody: Buffer.from('not-json'), + }) as Request & { rawBody?: Buffer }; + const { error } = collectNextError((next) => parseCapturedJson(req, makeRes(), next)); + assert.equal((error as { code?: string }).code, 'INVALID_BODY'); +}); diff --git a/src/webhooks/webhook.signature.ts b/src/webhooks/webhook.signature.ts index dceb0cb9..a0e91923 100644 --- a/src/webhooks/webhook.signature.ts +++ b/src/webhooks/webhook.signature.ts @@ -1,33 +1,47 @@ import crypto from 'crypto'; import type { Request, Response, NextFunction } from 'express'; import { BadRequestError, UnauthorizedError } from '../errors/index.js'; +import { WebhookNonceStore } from './webhook.nonceStore.js'; export const SIGNATURE_HEADER = 'x-callora-signature-256'; export const TIMESTAMP_HEADER = 'x-callora-timestamp'; +export const NONCE_HEADER = 'x-callora-nonce'; /** * Maximum age (ms) of a webhook request before it is rejected as a replay. - * Default: 5 minutes. + * Default: 5 minutes. Nonce records use the same TTL. */ export const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000; +/** Nonce must be unique, URL-safe, and long enough to resist guessing. */ +const NONCE_PATTERN = /^[A-Za-z0-9._-]{16,128}$/; + +const GENERIC_SIGNATURE_FAILURE = 'Webhook signature verification failed.'; + /** * Compute the expected HMAC-SHA256 signature for a webhook delivery. * - * The signed payload is: `.` - * This ties the signature to both the content and the delivery time, - * preventing replay attacks even when the same payload is re-sent. + * The signed payload is: `..` when a nonce is + * supplied, otherwise `.` (legacy callers / unit tests). + * Binding timestamp and nonce into the MAC prevents swapping either field + * on a captured request. * * @param secret - Shared secret stored at registration time. * @param timestamp - ISO-8601 delivery timestamp (from x-callora-timestamp header). * @param rawBody - Raw request body bytes (Buffer or string). + * @param nonce - Unique request nonce (from x-callora-nonce header). */ export function computeSignature( secret: string, timestamp: string, - rawBody: Buffer | string + rawBody: Buffer | string, + nonce?: string ): string { - const payload = `${timestamp}.${rawBody.toString()}`; + const body = rawBody.toString(); + const payload = + nonce !== undefined && nonce.length > 0 + ? `${timestamp}.${nonce}.${body}` + : `${timestamp}.${body}`; return crypto.createHmac('sha256', secret).update(payload).digest('hex'); } @@ -41,6 +55,48 @@ export function safeCompare(a: string, b: string): boolean { return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); } +/** + * Compare `receivedHex` against every provided secret without short-circuiting. + * Always walks the full list so the matched key cannot be inferred from timing + * or from the failure response. + * + * @returns true when at least one secret matches; never identifies which one. + */ +export function matchesAnySecret( + secrets: readonly string[], + timestamp: string, + rawBody: Buffer | string, + receivedHex: string, + nonce?: string +): boolean { + let matched = 0; + for (const secret of secrets) { + const expectedHex = computeSignature(secret, timestamp, rawBody, nonce); + if (safeCompare(expectedHex, receivedHex)) { + matched = 1; + } + } + return matched === 1; +} + +function headerValue(value: string | string[] | undefined): string | undefined { + if (typeof value === 'string') return value; + if (Array.isArray(value) && typeof value[0] === 'string') return value[0]; + return undefined; +} + +function nonceScope( + req: Request & { webhookNonceScope?: string } +): string { + if (typeof req.webhookNonceScope === 'string' && req.webhookNonceScope.length > 0) { + return req.webhookNonceScope; + } + const developerId = req.params?.developerId; + return typeof developerId === 'string' && developerId.length > 0 + ? developerId + : '_'; +} + /** * Express middleware: verify the HMAC-SHA256 signature on incoming webhook deliveries. * @@ -50,6 +106,7 @@ export function safeCompare(a: string, b: string): boolean { * - or `req.webhookSecrets` (string[]) containing current and unexpired previous secrets. * - `x-callora-signature-256` header — `sha256=` * - `x-callora-timestamp` header — ISO-8601 string + * - `x-callora-nonce` header — unique request nonce * - `req.rawBody` (Buffer) — populated by the `captureRawBody` middleware. * * If the secret is absent the middleware is a no-op (backwards compatible with @@ -58,10 +115,19 @@ export function safeCompare(a: string, b: string): boolean { * Rejects with 401 when: * - Headers are missing * - Timestamp is stale (> SIGNATURE_TOLERANCE_MS) - * - Signature does not match + * - Signature does not match any active key + * - Nonce has already been consumed + * + * Failure responses never identify which key (current vs previous) was tested + * or matched. */ export function verifyWebhookSignature( - req: Request & { webhookSecret?: string; webhookSecrets?: string[]; rawBody?: Buffer }, + req: Request & { + webhookSecret?: string; + webhookSecrets?: string[]; + webhookNonceScope?: string; + rawBody?: Buffer; + }, _res: Response, next: NextFunction ): void { @@ -72,12 +138,13 @@ export function verifyWebhookSignature( return next(); } - const sigHeader = req.headers[SIGNATURE_HEADER] as string | undefined; - const tsHeader = req.headers[TIMESTAMP_HEADER] as string | undefined; + const sigHeader = headerValue(req.headers[SIGNATURE_HEADER]); + const tsHeader = headerValue(req.headers[TIMESTAMP_HEADER]); + const nonceHeader = headerValue(req.headers[NONCE_HEADER]); - if (!sigHeader || !tsHeader) { + if (!sigHeader || !tsHeader || !nonceHeader) { next(new UnauthorizedError( - `Missing required headers: ${SIGNATURE_HEADER}, ${TIMESTAMP_HEADER}.`, + `Missing required headers: ${SIGNATURE_HEADER}, ${TIMESTAMP_HEADER}, ${NONCE_HEADER}.`, 'MISSING_WEBHOOK_SIGNATURE_HEADERS' )); return; @@ -112,20 +179,38 @@ export function verifyWebhookSignature( } const receivedHex = parts[1]; + if (!NONCE_PATTERN.test(nonceHeader)) { + next(new BadRequestError( + `Malformed ${NONCE_HEADER} header.`, + 'MALFORMED_WEBHOOK_NONCE' + )); + return; + } + const rawBody = req.rawBody ?? Buffer.alloc(0); - const hasValidSignature = secrets.some((secret) => { - const expectedHex = computeSignature(secret, tsHeader, rawBody); - return safeCompare(expectedHex, receivedHex); - }); + const accepted = matchesAnySecret(secrets, tsHeader, rawBody, receivedHex, nonceHeader); - if (!hasValidSignature) { + if (!accepted) { next(new UnauthorizedError( - 'Webhook signature verification failed.', + GENERIC_SIGNATURE_FAILURE, 'INVALID_WEBHOOK_SIGNATURE' )); return; } + const consumed = WebhookNonceStore.consume( + nonceScope(req), + nonceHeader, + SIGNATURE_TOLERANCE_MS + ); + if (!consumed) { + next(new UnauthorizedError( + GENERIC_SIGNATURE_FAILURE, + 'WEBHOOK_NONCE_REPLAYED' + )); + return; + } + next(); } @@ -154,3 +239,26 @@ export function captureRawBody( }); req.on('error', next); } + +/** + * Parse JSON from `req.rawBody` after `captureRawBody` has consumed the stream. + * `express.json()` cannot re-read the request once the raw bytes are buffered. + */ +export function parseCapturedJson( + req: Request & { rawBody?: Buffer }, + _res: Response, + next: NextFunction +): void { + if (!req.rawBody || req.rawBody.length === 0) { + req.body = {}; + next(); + return; + } + + try { + req.body = JSON.parse(req.rawBody.toString()); + next(); + } catch { + next(new BadRequestError('Invalid JSON body.', 'INVALID_BODY')); + } +} diff --git a/src/webhooks/webhook.store.test.ts b/src/webhooks/webhook.store.test.ts new file mode 100644 index 00000000..b3b1452d --- /dev/null +++ b/src/webhooks/webhook.store.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { WebhookStore } from './webhook.store.js'; +import { WebhookNonceStore } from './webhook.nonceStore.js'; +import type { WebhookConfig } from './webhook.types.js'; + +function baseConfig(overrides: Partial = {}): WebhookConfig { + return { + developerId: 'dev-store', + url: 'https://example.com/webhook', + events: ['new_api_call'], + createdAt: new Date('2026-06-25T11:00:00.000Z'), + ...overrides, + }; +} + +describe('WebhookStore rotation + deletion', () => { + beforeEach(() => { + WebhookStore.clear(); + }); + + it('rotateSecret keeps current and previous keys inside the grace window', () => { + WebhookStore.register(baseConfig({ + developerId: 'dev-rot', + secret_current: 'old-secret', + })); + + const expiresAt = new Date('2026-06-26T12:00:00.000Z'); + const rotated = WebhookStore.rotateSecret('dev-rot', 'new-secret', expiresAt); + assert.ok(rotated); + assert.equal(rotated.secret_current, 'new-secret'); + assert.equal(rotated.secret_previous, 'old-secret'); + + const now = new Date('2026-06-26T11:59:59.000Z'); + assert.deepEqual(WebhookStore.getActiveSecrets(rotated, now), ['new-secret', 'old-secret']); + }); + + it('getActiveSecrets deletes the previous key after the rotation window', () => { + const config = baseConfig({ + secret_current: 'new-secret', + secret_previous: 'old-secret', + previous_expires_at: new Date('2026-06-26T12:00:00.000Z'), + }); + WebhookStore.register(config); + + const stored = WebhookStore.get('dev-store')!; + const afterWindow = new Date('2026-06-26T12:00:01.000Z'); + assert.deepEqual(WebhookStore.getActiveSecrets(stored, afterWindow), ['new-secret']); + assert.equal(stored.secret_previous, undefined); + assert.equal(stored.previous_expires_at, undefined); + }); + + it('delete removes the webhook and purges persisted nonces', () => { + WebhookStore.register(baseConfig({ + secret_current: 'secret', + })); + assert.equal(WebhookNonceStore.consume('dev-store', 'nonce-abcdefghijklmnopqrst', 60_000), true); + + WebhookStore.delete('dev-store'); + assert.equal(WebhookStore.get('dev-store'), undefined); + assert.equal(WebhookNonceStore.has('dev-store', 'nonce-abcdefghijklmnopqrst'), false); + }); + + it('a second rotation replaces the previous key rather than accumulating', () => { + WebhookStore.register(baseConfig({ + developerId: 'dev-double', + secret_current: 's0', + })); + const t1 = new Date('2026-06-26T12:00:00.000Z'); + WebhookStore.rotateSecret('dev-double', 's1', t1); + const t2 = new Date('2026-06-27T12:00:00.000Z'); + WebhookStore.rotateSecret('dev-double', 's2', t2); + + const stored = WebhookStore.get('dev-double')!; + assert.equal(stored.secret_current, 's2'); + assert.equal(stored.secret_previous, 's1'); + assert.deepEqual( + WebhookStore.getActiveSecrets(stored, new Date('2026-06-26T12:00:00.000Z')), + ['s2', 's1'], + ); + }); +}); diff --git a/src/webhooks/webhook.store.ts b/src/webhooks/webhook.store.ts index 6d3518bf..44c33aaf 100644 --- a/src/webhooks/webhook.store.ts +++ b/src/webhooks/webhook.store.ts @@ -1,4 +1,5 @@ import { WebhookConfig, WebhookEventType, DeadLetterEntry, type RetryPolicy } from './webhook.types.js'; +import { WebhookNonceStore } from './webhook.nonceStore.js'; const store = new Map(); const deadLetterStore = new Map(); @@ -87,7 +88,29 @@ export const WebhookStore = { return nextConfig; }, + /** + * Drop the previous signing key once its grace window has elapsed. + * Mutates `config` in place (the object held in the store). + * @returns true when a previous key was deleted. + */ + expirePreviousSecret(config: WebhookConfig, now: Date = new Date()): boolean { + const expiredByClock = + !!config.previous_expires_at && + config.previous_expires_at.getTime() < now.getTime(); + const orphaned = !!config.secret_previous && !config.previous_expires_at; + + if (!config.secret_previous || (!expiredByClock && !orphaned)) { + return false; + } + + delete config.secret_previous; + delete config.previous_expires_at; + return true; + }, + getActiveSecrets(config: WebhookConfig, now: Date = new Date()): string[] { + this.expirePreviousSecret(config, now); + const secrets = new Set(); const currentSecret = config.secret_current ?? config.secret; @@ -108,6 +131,7 @@ export const WebhookStore = { delete(developerId: string): void { store.delete(developerId); + WebhookNonceStore.purgeScope(developerId); }, getByEvent(event: WebhookEventType): WebhookConfig[] { @@ -121,6 +145,7 @@ export const WebhookStore = { /** Clear all webhook configurations - for testing only */ clear(): void { store.clear(); + WebhookNonceStore.clear(); }, // ── Dead-Letter Queue (DLQ) ─────────────────────────────────────────────