From 36c49248a67f6fcdf60937f3336a23292e791496 Mon Sep 17 00:00:00 2001 From: apinator-dev Date: Sat, 17 Oct 2026 16:36:59 +0200 Subject: [PATCH] feat: verify webhooks with the endpoint's webhook secret Deliveries are signed with each endpoint's own webhook secret (whsec_..., shown in the console), not the app's API secret. client.verifyWebhook passed the API secret, so it could never verify a real delivery. It now takes the webhook secret as its first argument; the standalone verify function is unchanged. Also: - With a max age set, timestamps more than 300s in the future are rejected in every SDK (previously node/go rejected any future timestamp, python/php accepted all). - Shared conformance fixture gains verify_cases (array headers, skew, expiry, wrong secret), run by every SDK. - Docs and examples use the webhook secret, a 5 minute max age, the event payload shape ({id, event, app_id, channel, user_id, time_ms}) and dedupe on id. --- README.md | 53 ++++++++- docs/api-reference.md | 38 +++++- docs/quickstart.md | 11 ++ src/client.ts | 23 +++- src/webhook.ts | 29 +++-- test/client.test.ts | 86 +++++++++----- test/conformance.test.ts | 30 +++++ test/fixtures/webhook-signature.v1.json | 150 +++++++++++++++++++++++- test/webhook.test.ts | 42 ++++++- 9 files changed, 409 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 19bffcc..777619d 100644 --- a/README.md +++ b/README.md @@ -76,13 +76,64 @@ const auth = client.authenticateChannel( ); ``` +## Webhooks + +Each webhook endpoint you create in the console has its own signing secret +(`whsec_...`). Verify deliveries with that secret, not the app's API secret, and +pass the raw request body exactly as received. + +```ts +import express from "express"; +import { Apinator } from "@apinator/server"; + +const client = new Apinator({ /* appId, key, secret, cluster */ }); +const app = express(); +const processed = new Set(); // use a persistent store in production + +app.post("/webhooks/apinator", express.raw({ type: "application/json" }), (req, res) => { + const body = req.body.toString("utf8"); + + if (!client.verifyWebhook(process.env.APINATOR_WEBHOOK_SECRET!, req.headers, body, 300)) { + return res.status(401).send("Invalid signature"); + } + + const event = JSON.parse(body); + // {"id":"","event":"member_added","app_id":"...","channel":"presence-room", + // "user_id":"42","time_ms":1760000000000} + // user_id is only present on member_added / member_removed. + + // Deliveries are at-least-once: dedupe on event.id. + if (processed.has(event.id)) return res.sendStatus(200); + processed.add(event.id); + + switch (event.event) { + case "channel_occupied": + case "channel_vacated": + case "member_added": + case "member_removed": + // handle event + break; + } + res.sendStatus(200); +}); +``` + +Headers sent with each delivery: `X-Realtime-Signature` (`sha256=`), +`X-Realtime-Timestamp` (unix seconds), `X-Realtime-Event` and +`X-Realtime-Delivery-ID`. The console's "send test" button sends event +`webhook.test`. With `maxAge` set, timestamps older than `maxAge` seconds or more +than 300 seconds in the future are rejected. + +The standalone `verifyWebhook(webhookSecret, headers, body, maxAge?)` export does +the same without a client. + ## API - `trigger({ name, channel?, channels?, data, socketId? }): Promise` - `authenticateChannel(socketId, channelName, channelData?): { auth, channel_data? }` - `getChannels(prefix?): Promise` - `getChannel(channelName): Promise` -- `verifyWebhook(headers, body, maxAge?): boolean` +- `verifyWebhook(webhookSecret, headers, body, maxAge?): boolean` See: - [API Reference](docs/api-reference.md) diff --git a/docs/api-reference.md b/docs/api-reference.md index 2e86113..1260bdd 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -44,16 +44,44 @@ List channels, optionally filtered by prefix. Get channel details. -#### `verifyWebhook(headers, body, maxAge?): boolean` +#### `verifyWebhook(webhookSecret, headers, body, maxAge?): boolean` -Verify webhook signature. +Verify a webhook delivery's signature. -Accepted header shape: +- `webhookSecret: string` — the webhook endpoint's signing secret (`whsec_...`, + shown in the console per endpoint). Not the app's API secret. Throws + `ValidationError` if empty. +- `headers` — request headers (case-insensitive; array values use the first entry): -```ts -Record + ```ts + Record + ``` + +- `body: string` — raw request body, exactly as received. +- `maxAge?: number` — recommended `300`. When set, timestamps older than `maxAge` + seconds or more than 300 seconds in the future are rejected. + +The signature is `X-Realtime-Signature: sha256=hex(hmac_sha256(webhookSecret, "{timestamp}.{raw_body}"))` +with the timestamp from `X-Realtime-Timestamp`. Deliveries also carry +`X-Realtime-Event` and `X-Realtime-Delivery-ID`. + +Payload: + +```json +{"id":"","event":"member_added","app_id":"...","channel":"presence-room","user_id":"42","time_ms":1760000000000} ``` +`event` is one of `channel_occupied`, `channel_vacated`, `member_added`, +`member_removed` (`user_id` only on the member events); the console's test sends +`webhook.test`. Deliveries are at-least-once, so dedupe on `id`. + +## Standalone functions + +#### `verifyWebhook(secret, headers, body, maxAge?): boolean` + +Same check as the client method, without a client. `secret` is the webhook +signing secret. + ## Errors - `RealtimeError` diff --git a/docs/quickstart.md b/docs/quickstart.md index eec1415..1c1bfff 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -41,10 +41,21 @@ const auth = client.authenticateChannel( ## 5. Verify webhooks +Use the webhook endpoint's signing secret (`whsec_...`, from the console), not the +app's API secret, and the raw request body: + ```ts const isValid = client.verifyWebhook( + process.env.APINATOR_WEBHOOK_SECRET!, req.headers as Record, rawBody, 300 ); +if (!isValid) { + return res.status(401).send("Invalid signature"); +} + +const event = JSON.parse(rawBody); +// {"id":"...","event":"member_added","app_id":"...","channel":"presence-room","user_id":"42","time_ms":1760000000000} +// Deliveries are at-least-once: ignore an event.id you have already processed. ``` diff --git a/src/client.ts b/src/client.ts index 5a12936..b287730 100644 --- a/src/client.ts +++ b/src/client.ts @@ -237,34 +237,45 @@ export class Apinator { /** * Verify the authenticity of a webhook request. * - * This function verifies that the webhook was sent by the Realtime service - * by validating the HMAC signature in the request headers. + * Webhooks are signed with the webhook endpoint's own signing secret + * (`whsec_...`, shown in the console for each webhook endpoint), not with the + * app's API secret, so it must be passed explicitly. * + * @param webhookSecret - The webhook endpoint's signing secret (`whsec_...`) * @param headers - HTTP headers from the webhook request - * @param body - Raw request body as string + * @param body - Raw request body as string, exactly as received * @param maxAge - Optional maximum age in seconds for the webhook timestamp + * (300 recommended). When set, timestamps older than `maxAge` or more than + * 300 seconds in the future are rejected. * @returns true if the webhook is valid, false otherwise + * @throws {ValidationError} If `webhookSecret` is not a non-empty string * * @example Express.js webhook handler * ```ts * app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => { * const body = req.body.toString('utf8'); * - * if (!client.verifyWebhook(req.headers, body, 300)) { + * if (!client.verifyWebhook(process.env.APINATOR_WEBHOOK_SECRET!, req.headers, body, 300)) { * return res.status(401).send('Invalid signature'); * } * * const event = JSON.parse(body); - * // Process webhook... + * // Deliveries are at-least-once: skip event.id if already processed. * }); * ``` */ verifyWebhook( + webhookSecret: string, headers: Record, body: string, maxAge?: number ): boolean { - return verifyWebhookSignature(this.secret, headers, body, maxAge); + if (typeof webhookSecret !== "string" || webhookSecret === "") { + throw new ValidationError( + "verifyWebhook requires the webhook signing secret (whsec_...) as its first argument" + ); + } + return verifyWebhookSignature(webhookSecret, headers, body, maxAge); } /** diff --git a/src/webhook.ts b/src/webhook.ts index 20eec55..951bbe6 100644 --- a/src/webhook.ts +++ b/src/webhook.ts @@ -1,16 +1,28 @@ import { timingSafeEqual } from "crypto"; import { signWebhookPayload } from "./crypto"; +/** + * How far in the future (in seconds) a webhook timestamp may be before it is + * rejected, when a `maxAge` is given. Allows for clock skew between the + * Apinator servers and the receiver. + */ +export const WEBHOOK_MAX_FUTURE_SKEW_SECONDS = 300; + /** * Verify the authenticity of a webhook request. * - * This function verifies that the webhook was sent by the Realtime service - * by validating the HMAC signature in the request headers. + * This function verifies that the webhook was sent by Apinator by validating + * the `X-Realtime-Signature` header: + * `sha256=hex(hmac_sha256(secret, "{timestamp}.{raw_body}"))`. * - * @param secret - Webhook secret - * @param headers - HTTP headers from the webhook request (case-insensitive keys) - * @param body - Raw request body as string + * @param secret - The webhook endpoint's signing secret (`whsec_...`, shown in + * the console for each webhook endpoint). This is NOT the app's API secret. + * @param headers - HTTP headers from the webhook request (case-insensitive keys; + * array values use the first entry) + * @param body - Raw request body as string, exactly as received * @param maxAge - Optional maximum age in seconds for the webhook timestamp + * (300 recommended). When set, timestamps older than `maxAge` or more than + * 300 seconds in the future are rejected. * @returns true if the webhook is valid, false otherwise * * @example @@ -20,7 +32,7 @@ import { signWebhookPayload } from "./crypto"; * const headers = req.headers; * const body = req.body.toString('utf8'); * - * if (!verifyWebhook('my-webhook-secret', headers, body, 300)) { + * if (!verifyWebhook(process.env.APINATOR_WEBHOOK_SECRET!, headers, body, 300)) { * return res.status(401).send('Invalid signature'); * } * @@ -56,7 +68,8 @@ export function verifyWebhook( return false; } - // If maxAge provided, check timestamp is within maxAge of current time + // If maxAge provided, check timestamp is within maxAge in the past and + // within the allowed clock skew in the future if (maxAge !== undefined) { const webhookTime = parseInt(timestamp, 10); if (isNaN(webhookTime)) { @@ -66,7 +79,7 @@ export function verifyWebhook( const currentTime = Math.floor(Date.now() / 1000); const age = currentTime - webhookTime; - if (age > maxAge || age < 0) { + if (age > maxAge || -age > WEBHOOK_MAX_FUTURE_SKEW_SECONDS) { return false; } } diff --git a/test/client.test.ts b/test/client.test.ts index 5f2dcd9..5b5a5e5 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -351,67 +351,91 @@ describe("Apinator client", () => { }); describe("verifyWebhook", () => { - it("should verify valid webhook signature", () => { - const client = new Apinator(options); - - // Create a valid signature - const timestamp = Math.floor(Date.now() / 1000).toString(); - const body = '{"event":"channel_occupied","channel":"test"}'; + const webhookSecret = + "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const body = + '{"id":"cbb368a1-bc93-42bc-b939-51c7352cbd03","event":"member_added","app_id":"test-app-id","channel":"presence-room","user_id":"42","time_ms":1760000000000}'; - // We need to compute the signature using the same secret + function signedHeaders(secret: string, timestamp: string) { const crypto = require("crypto"); - const input = `${timestamp}.${body}`; const signature = crypto - .createHmac("sha256", options.secret) - .update(input, "utf8") + .createHmac("sha256", secret) + .update(`${timestamp}.${body}`, "utf8") .digest("hex"); - - const headers = { + return { "x-realtime-signature": `sha256=${signature}`, "x-realtime-timestamp": timestamp, }; + } + + it("should verify a webhook signed with the webhook secret", () => { + const client = new Apinator(options); + const timestamp = Math.floor(Date.now() / 1000).toString(); - const result = client.verifyWebhook(headers, body); + const result = client.verifyWebhook( + webhookSecret, + signedHeaders(webhookSecret, timestamp), + body, + 300 + ); expect(result).toBe(true); }); + it("should not use the client's API secret", () => { + const client = new Apinator(options); + const timestamp = Math.floor(Date.now() / 1000).toString(); + + // Signed with the API secret: must not verify against the webhook secret. + const result = client.verifyWebhook( + webhookSecret, + signedHeaders(options.secret, timestamp), + body + ); + expect(result).toBe(false); + }); + it("should reject invalid webhook signature", () => { const client = new Apinator(options); const timestamp = Math.floor(Date.now() / 1000).toString(); - const body = '{"event":"channel_occupied","channel":"test"}'; const headers = { "x-realtime-signature": "sha256=invalid", "x-realtime-timestamp": timestamp, }; - const result = client.verifyWebhook(headers, body); + const result = client.verifyWebhook(webhookSecret, headers, body); expect(result).toBe(false); }); it("should respect maxAge parameter", () => { const client = new Apinator(options); - - // Create timestamp 400 seconds ago const oldTimestamp = (Math.floor(Date.now() / 1000) - 400).toString(); - const body = '{"event":"channel_occupied","channel":"test"}'; + const headers = signedHeaders(webhookSecret, oldTimestamp); - const crypto = require("crypto"); - const input = `${oldTimestamp}.${body}`; - const signature = crypto - .createHmac("sha256", options.secret) - .update(input, "utf8") - .digest("hex"); + expect(client.verifyWebhook(webhookSecret, headers, body)).toBe(true); + expect(client.verifyWebhook(webhookSecret, headers, body, 300)).toBe(false); + }); - const headers = { - "x-realtime-signature": `sha256=${signature}`, - "x-realtime-timestamp": oldTimestamp, - }; + it("should reject a timestamp more than 300s in the future", () => { + const client = new Apinator(options); + const futureTimestamp = (Math.floor(Date.now() / 1000) + 600).toString(); + const headers = signedHeaders(webhookSecret, futureTimestamp); - // Should fail with maxAge of 300 seconds - const result = client.verifyWebhook(headers, body, 300); - expect(result).toBe(false); + expect(client.verifyWebhook(webhookSecret, headers, body, 300)).toBe(false); + }); + + it("should throw when the webhook secret is missing", () => { + const client = new Apinator(options); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const headers = signedHeaders(webhookSecret, timestamp); + + expect(() => client.verifyWebhook("", headers, body)).toThrow(ValidationError); + // Old call shape (headers first) from untyped JavaScript. + const legacyCall = client.verifyWebhook as unknown as ( + ...args: unknown[] + ) => boolean; + expect(() => legacyCall.call(client, headers, body, 300)).toThrow(ValidationError); }); }); }); diff --git a/test/conformance.test.ts b/test/conformance.test.ts index 8ab5b72..5d85d68 100644 --- a/test/conformance.test.ts +++ b/test/conformance.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { readFileSync } from "node:fs"; import { signRequest, signChannel, signWebhookPayload } from "../src/crypto"; import { authenticateChannel } from "../src/auth"; +import { verifyWebhook } from "../src/webhook"; import { Apinator } from "../src/client"; import { AuthenticationError } from "../src/errors"; @@ -79,6 +80,35 @@ describe("contract conformance", () => { } }); + it("matches webhook verification fixture cases", () => { + type VerifyCase = { + name: string; + secret: string; + signing_secret?: string; + body: string; + timestamp_offset: number; + max_age: number | null; + headers: Record; + expected_valid: boolean; + }; + const cases: VerifyCase[] = WEBHOOK_FIXTURE.verify_cases; + expect(cases.length).toBeGreaterThan(0); + + for (const c of cases) { + const timestamp = String(Math.floor(Date.now() / 1000) + c.timestamp_offset); + const signature = signWebhookPayload(c.signing_secret ?? c.secret, timestamp, c.body); + const fill = (v: string) => + v.replace("{timestamp}", timestamp).replace("{signature}", signature); + const headers: Record = {}; + for (const [k, v] of Object.entries(c.headers)) { + headers[k] = Array.isArray(v) ? v.map(fill) : fill(v); + } + + const valid = verifyWebhook(c.secret, headers, c.body, c.max_age ?? undefined); + expect(valid, c.name).toBe(c.expected_valid); + } + }); + it("parses RFC7807 error payloads", async () => { fetchMock.mockResolvedValue({ ok: false, diff --git a/test/fixtures/webhook-signature.v1.json b/test/fixtures/webhook-signature.v1.json index 4e4f2c9..4ac8fe2 100644 --- a/test/fixtures/webhook-signature.v1.json +++ b/test/fixtures/webhook-signature.v1.json @@ -16,5 +16,153 @@ "body": "{}", "expected_signature": "7c0deac78739d9b7e52b156c7a3d10a71d55f63fa6879851bbcdee002900def8" } + ], + "verify_notes": "Each verify case is run against the SDK's verify function at test time. timestamp = now (unix seconds) + timestamp_offset. signature = hex(hmac_sha256(signing_secret or secret, timestamp + \".\" + body)). In header values, {timestamp} and {signature} are replaced with those values; a header value may be a string or an array of strings. Verify with secret, headers, body and max_age (null means no freshness check). When max_age is set, timestamps older than max_age or more than 300 seconds in the future are rejected. expected_valid false also covers SDKs that signal rejection by throwing a validation error.", + "verify_cases": [ + { + "name": "valid-recent-with-max-age", + "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "body": "{\"id\":\"cbb368a1-bc93-42bc-b939-51c7352cbd03\",\"event\":\"member_added\",\"app_id\":\"app_123\",\"channel\":\"presence-room\",\"user_id\":\"42\",\"time_ms\":1760000000000}", + "timestamp_offset": -60, + "max_age": 300, + "headers": { + "X-Realtime-Signature": "sha256={signature}", + "X-Realtime-Timestamp": "{timestamp}" + }, + "expected_valid": true + }, + { + "name": "valid-lowercase-header-names", + "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "body": "{\"id\":\"cbb368a1-bc93-42bc-b939-51c7352cbd03\",\"event\":\"member_added\",\"app_id\":\"app_123\",\"channel\":\"presence-room\",\"user_id\":\"42\",\"time_ms\":1760000000000}", + "timestamp_offset": 0, + "max_age": 300, + "headers": { + "x-realtime-signature": "sha256={signature}", + "x-realtime-timestamp": "{timestamp}" + }, + "expected_valid": true + }, + { + "name": "valid-array-header-values", + "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "body": "{\"id\":\"9d1f2c4e-7a3b-4c5d-8e6f-0a1b2c3d4e5f\",\"event\":\"channel_occupied\",\"app_id\":\"app_123\",\"channel\":\"presence-room\",\"time_ms\":1760000000000}", + "timestamp_offset": -5, + "max_age": 300, + "headers": { + "x-realtime-signature": ["sha256={signature}"], + "x-realtime-timestamp": ["{timestamp}"], + "x-realtime-event": ["channel_occupied"] + }, + "expected_valid": true + }, + { + "name": "valid-signature-without-prefix", + "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "body": "{\"id\":\"cbb368a1-bc93-42bc-b939-51c7352cbd03\",\"event\":\"member_added\",\"app_id\":\"app_123\",\"channel\":\"presence-room\",\"user_id\":\"42\",\"time_ms\":1760000000000}", + "timestamp_offset": 0, + "max_age": 300, + "headers": { + "X-Realtime-Signature": "{signature}", + "X-Realtime-Timestamp": "{timestamp}" + }, + "expected_valid": true + }, + { + "name": "valid-small-future-skew", + "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "body": "{\"id\":\"cbb368a1-bc93-42bc-b939-51c7352cbd03\",\"event\":\"member_added\",\"app_id\":\"app_123\",\"channel\":\"presence-room\",\"user_id\":\"42\",\"time_ms\":1760000000000}", + "timestamp_offset": 60, + "max_age": 300, + "headers": { + "X-Realtime-Signature": "sha256={signature}", + "X-Realtime-Timestamp": "{timestamp}" + }, + "expected_valid": true + }, + { + "name": "valid-old-timestamp-without-max-age", + "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "body": "{\"id\":\"cbb368a1-bc93-42bc-b939-51c7352cbd03\",\"event\":\"member_added\",\"app_id\":\"app_123\",\"channel\":\"presence-room\",\"user_id\":\"42\",\"time_ms\":1760000000000}", + "timestamp_offset": -86400, + "max_age": null, + "headers": { + "X-Realtime-Signature": "sha256={signature}", + "X-Realtime-Timestamp": "{timestamp}" + }, + "expected_valid": true + }, + { + "name": "reject-expired-timestamp", + "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "body": "{\"id\":\"cbb368a1-bc93-42bc-b939-51c7352cbd03\",\"event\":\"member_added\",\"app_id\":\"app_123\",\"channel\":\"presence-room\",\"user_id\":\"42\",\"time_ms\":1760000000000}", + "timestamp_offset": -600, + "max_age": 300, + "headers": { + "X-Realtime-Signature": "sha256={signature}", + "X-Realtime-Timestamp": "{timestamp}" + }, + "expected_valid": false + }, + { + "name": "reject-future-timestamp", + "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "body": "{\"id\":\"cbb368a1-bc93-42bc-b939-51c7352cbd03\",\"event\":\"member_added\",\"app_id\":\"app_123\",\"channel\":\"presence-room\",\"user_id\":\"42\",\"time_ms\":1760000000000}", + "timestamp_offset": 600, + "max_age": 300, + "headers": { + "X-Realtime-Signature": "sha256={signature}", + "X-Realtime-Timestamp": "{timestamp}" + }, + "expected_valid": false + }, + { + "name": "reject-future-timestamp-despite-large-max-age", + "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "body": "{\"id\":\"cbb368a1-bc93-42bc-b939-51c7352cbd03\",\"event\":\"member_added\",\"app_id\":\"app_123\",\"channel\":\"presence-room\",\"user_id\":\"42\",\"time_ms\":1760000000000}", + "timestamp_offset": 600, + "max_age": 3600, + "headers": { + "X-Realtime-Signature": "sha256={signature}", + "X-Realtime-Timestamp": "{timestamp}" + }, + "expected_valid": false + }, + { + "name": "reject-signed-with-app-api-secret", + "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "signing_secret": "app-api-secret", + "body": "{\"id\":\"cbb368a1-bc93-42bc-b939-51c7352cbd03\",\"event\":\"member_added\",\"app_id\":\"app_123\",\"channel\":\"presence-room\",\"user_id\":\"42\",\"time_ms\":1760000000000}", + "timestamp_offset": 0, + "max_age": 300, + "headers": { + "X-Realtime-Signature": "sha256={signature}", + "X-Realtime-Timestamp": "{timestamp}" + }, + "expected_valid": false + }, + { + "name": "reject-wrong-signature", + "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "body": "{\"id\":\"cbb368a1-bc93-42bc-b939-51c7352cbd03\",\"event\":\"member_added\",\"app_id\":\"app_123\",\"channel\":\"presence-room\",\"user_id\":\"42\",\"time_ms\":1760000000000}", + "timestamp_offset": 0, + "max_age": 300, + "headers": { + "X-Realtime-Signature": "sha256=0000000000000000000000000000000000000000000000000000000000000000", + "X-Realtime-Timestamp": "{timestamp}" + }, + "expected_valid": false + }, + { + "name": "reject-missing-signature-header", + "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "body": "{\"id\":\"cbb368a1-bc93-42bc-b939-51c7352cbd03\",\"event\":\"member_added\",\"app_id\":\"app_123\",\"channel\":\"presence-room\",\"user_id\":\"42\",\"time_ms\":1760000000000}", + "timestamp_offset": 0, + "max_age": 300, + "headers": { + "X-Realtime-Timestamp": "{timestamp}" + }, + "expected_valid": false + } ] -} \ No newline at end of file +} diff --git a/test/webhook.test.ts b/test/webhook.test.ts index c5efa3d..ebbbc0e 100644 --- a/test/webhook.test.ts +++ b/test/webhook.test.ts @@ -161,7 +161,7 @@ describe("webhook", () => { expect(result).toBe(false); }); - it("should return false for future timestamp", () => { + it("should accept a timestamp slightly in the future (clock skew)", () => { const currentTime = Math.floor(Date.now() / 1000); const timestamp = (currentTime + 100).toString(); // 100 seconds in the future const body = '{"event":"channel_occupied","channel":"test"}'; @@ -172,10 +172,50 @@ describe("webhook", () => { "x-realtime-timestamp": timestamp, }; + expect(verifyWebhook(secret, headers, body, 300)).toBe(true); + }); + + it("should return false for a timestamp more than 300s in the future", () => { + const currentTime = Math.floor(Date.now() / 1000); + const timestamp = (currentTime + 400).toString(); // 400 seconds in the future + const body = '{"event":"channel_occupied","channel":"test"}'; + const signature = signWebhookPayload(secret, timestamp, body); + + const headers = { + "x-realtime-signature": `sha256=${signature}`, + "x-realtime-timestamp": timestamp, + }; + const result = verifyWebhook(secret, headers, body, 300); expect(result).toBe(false); }); + it("should reject a far-future timestamp even with a large maxAge", () => { + const timestamp = (Math.floor(Date.now() / 1000) + 600).toString(); + const body = '{"event":"channel_occupied","channel":"test"}'; + const signature = signWebhookPayload(secret, timestamp, body); + + const headers = { + "x-realtime-signature": `sha256=${signature}`, + "x-realtime-timestamp": timestamp, + }; + + expect(verifyWebhook(secret, headers, body, 3600)).toBe(false); + }); + + it("should not check freshness without maxAge", () => { + const timestamp = (Math.floor(Date.now() / 1000) + 600).toString(); + const body = '{"event":"channel_occupied","channel":"test"}'; + const signature = signWebhookPayload(secret, timestamp, body); + + const headers = { + "x-realtime-signature": `sha256=${signature}`, + "x-realtime-timestamp": timestamp, + }; + + expect(verifyWebhook(secret, headers, body)).toBe(true); + }); + it("should return false for invalid timestamp", () => { const timestamp = "not-a-number"; const body = '{"event":"channel_occupied","channel":"test"}';