From 737dd7af9878006a2878b0c1e806fee98cd49b87 Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Sat, 15 Aug 2026 14:39:41 +0800 Subject: [PATCH] feat(reliability): idempotency store, custom webhook replay protection, correlation ids Add LICENSE (MIT), an IdempotencyStore abstraction with a KV implementation and provider-scoped delivery keys, optional replay protection for custom webhooks (X-WebHooker-Timestamp + X-WebHooker-Nonce), and per-request correlation ids in webhook responses and logs. --- AGENTS.md | 7 +- LICENSE | 21 +++++ README.md | 2 +- README.zh.md | 2 +- docs/api/overview.md | 2 +- docs/guide/ingress.md | 21 ++++- docs/zh/api/overview.md | 2 +- docs/zh/guide/ingress.md | 21 ++++- server/lib/lib/correlation.ts | 7 ++ server/lib/lib/idempotency.ts | 39 +++++++++ server/lib/providers/custom/index.ts | 56 +++++++++++- server/lib/webhook.ts | 35 +++++--- tests/custom-replay.test.ts | 126 +++++++++++++++++++++++++++ tests/idempotency.test.ts | 61 +++++++++++++ tests/webhook-tenant.test.ts | 2 +- 15 files changed, 378 insertions(+), 26 deletions(-) create mode 100644 LICENSE create mode 100644 server/lib/lib/correlation.ts create mode 100644 server/lib/lib/idempotency.ts create mode 100644 tests/custom-replay.test.ts create mode 100644 tests/idempotency.test.ts diff --git a/AGENTS.md b/AGENTS.md index 0dedb60..c80b89a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,8 @@ server/ # Nitro server │ └── richheader.ts # GET /api/richheader: Open Graph page for Telegram avatar link-preview card └── lib/ # shared infra ├── i18n.ts # loadTranslations (KV i18n:{lang} overrides), t() with param interpolation + ├── idempotency.ts # IdempotencyStore interface + kvIdempotencyStore (delivery dedup via claim/has) + deliveryKey + ├── correlation.ts # newCorrelationId() — per-request/delivery correlation id for logs + responses ├── send-log.ts # SendRecord, recordSend/getSendLog/getSendLogById (D1 send_logs) ├── audit.ts # recordAudit/getAuditLog/pruneAuditLogs (D1 audit_logs, best-effort writes) ├── log.ts # JSON console logger (info/warn/error/fatal) @@ -113,7 +115,7 @@ tests/ # bun test unit tests (webhook, formatter, discord, tel - Verify GitHub webhook signatures (Web Crypto HMAC-SHA256, `X-Hub-Signature-256`) - Verify Gitea webhook signatures (Web Crypto HMAC-SHA256, plain hex `X-Gitea-Signature`) -- Verify custom webhook signatures (Web Crypto HMAC-SHA256, GitHub-style `sha256=` via `X-WebHooker-Signature`) +- Verify custom webhook signatures (Web Crypto HMAC-SHA256, GitHub-style `sha256=` via `X-WebHooker-Signature`; optional replay protection via `X-WebHooker-Timestamp` + `X-WebHooker-Nonce` — signature over `{timestamp}.{nonce}.{body}`, ±5 min window, nonce dedup in KV) - Normalize Gitea webhook payloads to a GitHub-shaped `WebhookEvent` (push `compare_url` → `compare`, `pull_request_comment` → `pull_request_review_comment`, ...) - Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body) - Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured) @@ -129,7 +131,8 @@ tests/ # bun test unit tests (webhook, formatter, discord, tel - Route messages to Discord channels/threads and Telegram chats/topics via REST - Edit already-sent messages in place for `workflow_run` / `check_run` progress (stable `updateKey`, KV `msg:*` tracking) - Record every dispatch attempt to D1 `send_logs` (route id, event, target, ok/error, duration, error code) -- Serve a per-group webhook ingress (`POST /webhook/{groupId}`, per-group secret in KV `tenant:{groupId}`) for Gitea/classic-GitHub/custom senders; only that group's routes fire; dedup keys are tenant-scoped +- Serve a per-group webhook ingress (`POST /webhook/{groupId}`, per-group secret in KV `tenant:{groupId}`) for Gitea/classic-GitHub/custom senders; only that group's routes fire; dedup keys are provider- and tenant-scoped (`delivery:{provider}:{groupId}:{id}` via `kvIdempotencyStore`) +- Issue a per-request correlation id (`requestId`) in webhook responses and dispatch logs - Send a per-event summary (event, repo, delivery id, per route×target ✅/❌ outcome) to the group's `logTarget` when configured - Serve `/gh` slash commands + message context-menu commands + PR merge/close buttons + comment modals - Serve Telegram `/gh` commands (login/logout/comment/merge/close) via reply-message parsing diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3188a88 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 WebHooker contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ddc3d24..5ab702d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook event - **28 event formatters** — push, pull_request, issues, issue_comment, workflow_run, workflow_job, status, deployment, deployment_status, check_run, check_suite, ping, release, create, delete, star, fork, pull_request_review, pull_request_review_comment, commit_comment, member, label, milestone, discussion, discussion_comment, repository, code_scanning_alert, dependabot_alert (+ generic fallback, + `custom` webhooks) - **Multi-provider webhooks** — GitHub (`X-Hub-Signature-256`) and Gitea (`X-Gitea-Signature`) share one `/webhook` endpoint; the provider is auto-detected from headers -- **Per-group webhook ingress** — every group can get its own `POST /webhook/{groupId}` URL + secret (Gitea, classic GitHub webhooks, and arbitrary custom JSON posts signed with `X-WebHooker-Signature`) +- **Per-group webhook ingress** — every group can get its own `POST /webhook/{groupId}` URL + secret (Gitea, classic GitHub webhooks, and arbitrary custom JSON posts signed with `X-WebHooker-Signature`, with optional timestamp+nonce replay protection) - **GitHub App tenant isolation** — bind a group to a GitHub App installation id so only that org/user's events enter it - HMAC-SHA256 signature verification (Web Crypto API) - Filter by event type, repo, actor, action, branch, keyword (supports `*`/`?` globs and `/regex/`) diff --git a/README.zh.md b/README.zh.md index 6b81c1d..91dd8cd 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,7 +6,7 @@ GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare W - **28 种事件格式化** — push、pull_request、issues、issue_comment、workflow_run、workflow_job、status、deployment、deployment_status、check_run、check_suite、ping、release、create、delete、star、fork、pull_request_review、pull_request_review_comment、commit_comment、member、label、milestone、discussion、discussion_comment、repository、code_scanning_alert、dependabot_alert(+ 通用回退,+ `custom` 自定义 webhook) - **多平台 webhook** — GitHub(`X-Hub-Signature-256`)与 Gitea(`X-Gitea-Signature`)共用 `/webhook` 端点,自动识别来源平台 -- **分组级 webhook 入口** — 每个分组可拥有独立的 `POST /webhook/{groupId}` URL + secret(Gitea、classic GitHub webhook,以及用 `X-WebHooker-Signature` 签名的任意自定义 JSON) +- **分组级 webhook 入口** — 每个分组可拥有独立的 `POST /webhook/{groupId}` URL + secret(Gitea、classic GitHub webhook,以及用 `X-WebHooker-Signature` 签名的任意自定义 JSON,可选的 timestamp+nonce 重放防护) - **GitHub App 租户隔离** — 将分组绑定到 GitHub App 安装 ID,只有该组织/用户的事件才能进入该分组 - HMAC-SHA256 签名验证(Web Crypto API) - 按事件类型、仓库、操作人、操作、分支、关键词过滤(支持 `*`/`?` 通配符与 `/正则/`) diff --git a/docs/api/overview.md b/docs/api/overview.md index c5ded28..442f653 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -94,7 +94,7 @@ Verifies the payload against the **group's** secret (KV `tenant:{groupId}`, gene ### Custom Webhooks -Any JSON payload signed with `X-WebHooker-Signature: sha256=` (HMAC-SHA256 of the raw body, group or global secret) becomes a `custom` event. Route it with a route whose filter is `event: custom`. Payload schema: see [Configuration → Custom webhooks](../guide/ingress.md#custom-webhooks). +Any JSON payload signed with `X-WebHooker-Signature: sha256=` (HMAC-SHA256 of the raw body, group or global secret) becomes a `custom` event. Route it with a route whose filter is `event: custom`. Optional `X-WebHooker-Timestamp` + `X-WebHooker-Nonce` headers enable replay protection (signature over `{timestamp}.{nonce}.{body}`, ±5 min window, nonce dedup). Payload schema: see [Configuration → Custom webhooks](../guide/ingress.md#custom-webhooks). ### GitHub App Installation Events diff --git a/docs/guide/ingress.md b/docs/guide/ingress.md index 12da507..4fdf985 100644 --- a/docs/guide/ingress.md +++ b/docs/guide/ingress.md @@ -23,13 +23,32 @@ Every group can opt into its own webhook ingress with an independent secret (gen - Supported for any provider: GitHub (`X-Hub-Signature-256`), Gitea (`X-Gitea-Signature`), custom (`X-WebHooker-Signature`) - The secret is a 64-char hex string; regenerate from the console invalidates the old one immediately -- Delivery-id dedup keys are tenant-scoped (`delivery:{groupId}:{id}`) +- Delivery-id dedup keys are provider- and tenant-scoped (`delivery:{provider}:{groupId}:{id}`) - When the group has no secret (or no longer exists) the endpoint returns `404` ## Custom Webhooks Post arbitrary JSON to `POST /webhook/{groupId}` (or the global endpoint) with the body signed as `X-WebHooker-Signature: sha256=` using the group's secret. The payload becomes a `custom` event that flows through the normal route pipeline — create a route with `event: custom` (there is a console template) and it dispatches to that route's targets, records `send_logs`, and appears in the group's webhook log channel. +### Replay Protection + +Custom webhooks support optional replay protection via two extra headers alongside the signature: + +- `X-WebHooker-Timestamp` — Unix seconds the request was sent +- `X-WebHooker-Nonce` — a unique, unpredictable value per request (e.g. a UUID) + +When **both** headers are present, the signature is computed over `{timestamp}.{nonce}.{rawBody}` instead of the raw body, and the request is accepted only if: + +1. The timestamp is within ±5 minutes of the server clock (rejects replays and clock-drift abusers) +2. The nonce has never been seen before (stored in KV for 10 minutes; a replayed nonce is rejected) + +```bash +input="${timestamp}.${nonce}.${body}" +signature="sha256=$(printf '%s' "$input" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/.*= //')" +``` + +When the headers are omitted, WebHooker falls back to the legacy body-only signature, so existing senders keep working unchanged. + Payload schema: ```json diff --git a/docs/zh/api/overview.md b/docs/zh/api/overview.md index 13f965c..c49afd0 100644 --- a/docs/zh/api/overview.md +++ b/docs/zh/api/overview.md @@ -94,7 +94,7 @@ POST /webhook ### 自定义 Webhook -任意 JSON 载荷用 `X-WebHooker-Signature: sha256=`(对原始 body 的 HMAC-SHA256,使用分组或全局 secret)签名后即可成为 `custom` 事件。用 `event: custom` 过滤器的路由接收。载荷格式见[配置 → 自定义 Webhook](../guide/ingress.md#自定义-webhook)。 +任意 JSON 载荷用 `X-WebHooker-Signature: sha256=`(对原始 body 的 HMAC-SHA256,使用分组或全局 secret)签名后即可成为 `custom` 事件。用 `event: custom` 过滤器的路由接收。可选的 `X-WebHooker-Timestamp` + `X-WebHooker-Nonce` 请求头启用重放防护(对 `{timestamp}.{nonce}.{body}` 签名、±5 分钟窗口、nonce 去重)。载荷格式见[配置 → 自定义 Webhook](../guide/ingress.md#自定义-webhook)。 ### GitHub App 安装事件 diff --git a/docs/zh/guide/ingress.md b/docs/zh/guide/ingress.md index 78b6c5a..b92d1b4 100644 --- a/docs/zh/guide/ingress.md +++ b/docs/zh/guide/ingress.md @@ -23,13 +23,32 @@ Gitea 载荷会被归一化为与 GitHub 事件相同的内部结构,因此路 - 支持任意提供方:GitHub(`X-Hub-Signature-256`)、Gitea(`X-Gitea-Signature`)、自定义(`X-WebHooker-Signature`) - 密钥为 64 位 hex 字符串;在控制台重新生成会立即失效旧密钥 -- 投递 id 去重键按租户隔离(`delivery:{groupId}:{id}`) +- 投递 id 去重键按提供方与租户隔离(`delivery:{provider}:{groupId}:{id}`) - 分组没有密钥(或已不存在)时端点返回 `404` ## 自定义 Webhook 向 `POST /webhook/{groupId}`(或全局端点)POST 任意 JSON,并使用分组密钥将原始 body 的 HMAC-SHA256 以 `X-WebHooker-Signature: sha256=` 签名。载荷会变成 `custom` 事件,走正常的路由管线——创建一条 `event: custom` 的路由(控制台有模板),即可分发到该路由的目标、记录 `send_logs`,并出现在分组的 webhook 日志频道中。 +### 重放防护 + +自定义 webhook 支持可选的防重放机制,在签名之外再附带两个请求头: + +- `X-WebHooker-Timestamp` — 请求发送时的 Unix 秒数 +- `X-WebHooker-Nonce` — 每次请求唯一且不可预测的值(如 UUID) + +当**同时**提供这两个请求头时,签名改为对 `{timestamp}.{nonce}.{原始body}` 计算(而非仅原始 body),且仅当以下条件满足时才被接受: + +1. 时间戳与服务器时钟相差不超过 ±5 分钟(拒绝重放与时钟漂移滥用) +2. nonce 从未被使用过(存入 KV 保留 10 分钟;重放的 nonce 会被拒绝) + +```bash +input="${timestamp}.${nonce}.${body}" +signature="sha256=$(printf '%s' "$input" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/.*= //')" +``` + +省略这些请求头时,WebHooker 回退到旧版的仅对 body 签名,现有发送方无需改动即可继续工作。 + 载荷模式: ```json diff --git a/server/lib/lib/correlation.ts b/server/lib/lib/correlation.ts new file mode 100644 index 0000000..13e116d --- /dev/null +++ b/server/lib/lib/correlation.ts @@ -0,0 +1,7 @@ +/** A short, unique correlation id for tracing one webhook request. */ +export function newCorrelationId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/server/lib/lib/idempotency.ts b/server/lib/lib/idempotency.ts new file mode 100644 index 0000000..2de9607 --- /dev/null +++ b/server/lib/lib/idempotency.ts @@ -0,0 +1,39 @@ +/** + * Delivery idempotency: a small, reusable abstraction over "has this key been + * seen / can I claim it once" so webhook dedup, nonce replay protection and + * future delivery retry all share one semantics. `claim` is best-effort atomic + * on KV (get-then-put): under a concurrent double-send both callers may see + * "unclaimed", but that matches the existing dedup behavior and is acceptable + * because dispatch itself is idempotent per (provider, group, delivery). + */ +export interface IdempotencyStore { + has(key: string): Promise; + claim(key: string, ttlSeconds: number): Promise; +} + +export function kvIdempotencyStore(kv: KVNamespace): IdempotencyStore { + return { + async has(key): Promise { + const value = await kv.get(key); + return value !== null && value !== undefined; + }, + async claim(key, ttlSeconds): Promise { + const value = await kv.get(key); + if (value !== null && value !== undefined) return false; + await kv.put(key, "1", { expirationTtl: ttlSeconds }); + return true; + }, + }; +} + +/** + * Canonical dedup key for a webhook delivery: provider + tenant + delivery id. + * The legacy global endpoint has no tenant, so `groupId` is "global". + */ +export function deliveryKey( + provider: string, + groupId: string | undefined, + deliveryId: string, +): string { + return `delivery:${provider}:${groupId ?? "global"}:${deliveryId}`; +} diff --git a/server/lib/providers/custom/index.ts b/server/lib/providers/custom/index.ts index 2b54971..f77e885 100644 --- a/server/lib/providers/custom/index.ts +++ b/server/lib/providers/custom/index.ts @@ -1,12 +1,55 @@ import type { Env, WebhookEvent } from "../../types"; import type { Provider } from "../types"; +import { hmacSha256Hex, timingSafeEqual } from "../hmac"; import { verifySignature } from "../github/verify"; +const REPLAY_WINDOW_SECONDS = 300; +const NONCE_TTL_SECONDS = 600; + +function parseTimestamp(value: string | undefined): number | null { + if (!value) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +/** + * Replay protection for `custom` webhooks: the sender adds `X-WebHooker-Timestamp` + * (Unix seconds) and `X-WebHooker-Nonce` and signs + * `timestamp + "." + nonce + "." + body` instead of the bare body. We reject + * timestamps outside a ±5min window and reject any nonce already seen (nonces + * live in KV for 10min). Senders without those headers keep the legacy + * body-only signature so existing integrations continue to work. + */ +async function verifyReplayProtected( + body: string, + headers: Record, + secret: string, + kv: KVNamespace, +): Promise { + const timestamp = parseTimestamp(headers["x-webhooker-timestamp"]); + const nonce = headers["x-webhooker-nonce"]; + const signature = headers["x-webhooker-signature"]; + if (timestamp == null || !nonce || !secret) return false; + + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - timestamp) > REPLAY_WINDOW_SECONDS) return false; + + const expected = `sha256=${await hmacSha256Hex(secret, `${timestamp}.${nonce}.${body}`)}`; + if (!timingSafeEqual(signature ?? "", expected)) return false; + + const nonceKey = `nonce:${nonce}`; + const seen = await kv.get(nonceKey); + if (seen !== null && seen !== undefined) return false; + await kv.put(nonceKey, "1", { expirationTtl: NONCE_TTL_SECONDS }); + return true; +} + /** * Custom webhook provider: accepts arbitrary JSON posts (monitoring, CI, - * scripts, ...) that are not signed by a forge. The sender signs the raw body + * scripts, ...) that are not signed by a forge. The sender signs the payload * with the tenant's secret using the GitHub-style `sha256=` HMAC header - * `X-WebHooker-Signature`. Payloads become `custom` events that flow through + * `X-WebHooker-Signature`, optionally adding replay-protection headers (see + * `verifyReplayProtected`). Payloads become `custom` events that flow through * the normal route matching pipeline (a route with `event: custom`). */ export const customProvider: Provider = { @@ -24,7 +67,14 @@ export const customProvider: Provider = { // The tenant webhook handler overrides GITHUB_WEBHOOK_SECRET with the // group's secret; on the legacy global endpoint this falls back to the // operator's global secret. - return verifySignature(body, headers["x-webhooker-signature"], env.GITHUB_WEBHOOK_SECRET); + const secret = env.GITHUB_WEBHOOK_SECRET; + if ( + headers["x-webhooker-timestamp"] !== undefined && + headers["x-webhooker-nonce"] !== undefined + ) { + return verifyReplayProtected(body, headers, secret, env.KV); + } + return verifySignature(body, headers["x-webhooker-signature"], secret); }, parse(body, _headers): WebhookEvent | null { diff --git a/server/lib/webhook.ts b/server/lib/webhook.ts index 2119150..d115ac7 100644 --- a/server/lib/webhook.ts +++ b/server/lib/webhook.ts @@ -9,6 +9,8 @@ import { getTenantSecret } from "./web/tenants"; import { recordAudit } from "./lib/audit"; import { cfEnv, cfWaitUntil, headersFrom } from "./cf"; import { log } from "./lib/log"; +import { deliveryKey, kvIdempotencyStore } from "./lib/idempotency"; +import { newCorrelationId } from "./lib/correlation"; const MAX_BODY_SIZE = 1024 * 1024; @@ -32,6 +34,7 @@ export async function processWebhook( waitUntil: (promise: Promise) => void, tenantId?: string, ): Promise { + const requestId = newCorrelationId(); let effectiveEnv = env; const groups = await loadGroups(env.KV); if (tenantId) { @@ -56,7 +59,10 @@ export async function processWebhook( } catch (err) { // A malformed secret or an unavailable crypto implementation must fail as // a clean 401, never an uncaught 500. - log.warn({ provider: provider.id, err: String(err) }, "Webhook signature verification failed"); + log.warn( + { provider: provider.id, requestId, err: String(err) }, + "Webhook signature verification failed", + ); return { status: 401, body: { error: "Invalid signature" } }; } if (!verified) { @@ -67,9 +73,12 @@ export async function processWebhook( ? effectiveEnv.GITEA_WEBHOOK_SECRET : effectiveEnv.GITHUB_WEBHOOK_SECRET; if (!secret) { - log.warn({ provider: provider.id }, "Webhook rejected: provider secret is not configured"); + log.warn( + { provider: provider.id, requestId }, + "Webhook rejected: provider secret is not configured", + ); } else { - log.warn({ provider: provider.id }, "Webhook rejected: invalid signature"); + log.warn({ provider: provider.id, requestId }, "Webhook rejected: invalid signature"); } return { status: 401, body: { error: "Invalid signature" } }; } @@ -111,16 +120,14 @@ export async function processWebhook( } if (event.deliveryId) { - // Tenant-scoped dedup keys: different accounts can reuse the same - // delivery id, so the global key would wrongly dedupe across tenants. - const key = tenantId - ? `delivery:${tenantId}:${event.deliveryId}` - : `delivery:${event.deliveryId}`; - const seen = await env.KV.get(key); - if (seen) { - return { status: 200, body: { ok: true, duplicate: true } }; + // Dedup via the idempotency store: a provider/tenant-scoped key means + // different accounts may reuse a delivery id without colliding, while + // retries of the same delivery never dispatch twice. + const store = kvIdempotencyStore(env.KV); + const key = deliveryKey(provider.id, tenantId, event.deliveryId); + if (!(await store.claim(key, 300))) { + return { status: 200, body: { ok: true, duplicate: true, requestId } }; } - await env.KV.put(key, "1", { expirationTtl: 300 }); } const config = await loadConfig(env); @@ -129,11 +136,11 @@ export async function processWebhook( } const dispatch = dispatchEvent(config, event, env, groups).catch((err) => - log.error(err, "Dispatch failed"), + log.error({ requestId, err }, "Dispatch failed"), ); waitUntil(dispatch); - return { status: 200, body: { ok: true } }; + return { status: 200, body: { ok: true, requestId } }; } /** h3 wrapper for `POST /webhook` / `POST /webhook/:groupId`. */ diff --git a/tests/custom-replay.test.ts b/tests/custom-replay.test.ts new file mode 100644 index 0000000..4d6bbba --- /dev/null +++ b/tests/custom-replay.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "bun:test"; +import { createHmac } from "crypto"; +import { customProvider } from "../server/lib/providers/custom"; +import type { Env } from "../server/lib/types"; + +function createMockKV(): KVNamespace { + const store = new Map(); + return { + get: async (key: string) => (store.has(key) ? store.get(key)! : null), + put: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + list: async () => ({ keys: [], list_complete: true, cacheStatus: null }), + } as unknown as KVNamespace; +} + +function createEnv(secret: string): Env { + return { + GITHUB_WEBHOOK_SECRET: secret, + KV: createMockKV(), + DB: {} as D1Database, + }; +} + +function replaySignature(secret: string, timestamp: number, nonce: string, body: string): string { + return `sha256=${createHmac("sha256", secret).update(`${timestamp}.${nonce}.${body}`).digest("hex")}`; +} + +const now = Math.floor(Date.now() / 1000); + +describe("custom provider replay protection", () => { + it("verifies a valid timestamp + nonce + signature", async () => { + const secret = "s3cret"; + const env = createEnv(secret); + const body = JSON.stringify({ hello: "world" }); + const nonce = "nonce-1"; + const ok = await customProvider.verify( + body, + { + "x-webhooker-signature": replaySignature(secret, now, nonce, body), + "x-webhooker-timestamp": String(now), + "x-webhooker-nonce": nonce, + }, + env, + ); + expect(ok).toBe(true); + }); + + it("rejects a replayed nonce even with a valid signature", async () => { + const secret = "s3cret"; + const env = createEnv(secret); + const body = JSON.stringify({ hello: "world" }); + const nonce = "nonce-reused"; + const headers = { + "x-webhooker-signature": replaySignature(secret, now, nonce, body), + "x-webhooker-timestamp": String(now), + "x-webhooker-nonce": nonce, + }; + expect(await customProvider.verify(body, headers, env)).toBe(true); + expect(await customProvider.verify(body, headers, env)).toBe(false); + }); + + it("rejects a timestamp outside the window", async () => { + const secret = "s3cret"; + const env = createEnv(secret); + const body = JSON.stringify({ hello: "world" }); + const stale = now - 3600; + const nonce = "nonce-stale"; + const ok = await customProvider.verify( + body, + { + "x-webhooker-signature": replaySignature(secret, stale, nonce, body), + "x-webhooker-timestamp": String(stale), + "x-webhooker-nonce": nonce, + }, + env, + ); + expect(ok).toBe(false); + }); + + it("rejects a signature computed over the wrong input", async () => { + const secret = "s3cret"; + const env = createEnv(secret); + const body = JSON.stringify({ hello: "world" }); + const nonce = "nonce-bad-sig"; + const wrong = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; + const ok = await customProvider.verify( + body, + { + "x-webhooker-signature": wrong, + "x-webhooker-timestamp": String(now), + "x-webhooker-nonce": nonce, + }, + env, + ); + expect(ok).toBe(false); + }); + + it("still verifies the legacy body-only signature", async () => { + const secret = "s3cret"; + const env = createEnv(secret); + const body = JSON.stringify({ hello: "world" }); + const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; + const ok = await customProvider.verify(body, { "x-webhooker-signature": sig }, env); + expect(ok).toBe(true); + }); + + it("treats a missing nonce as a legacy (body-only) signature", async () => { + const secret = "s3cret"; + const env = createEnv(secret); + const body = JSON.stringify({ hello: "world" }); + // Only a timestamp, no nonce → falls back to body-only verification. + const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; + const ok = await customProvider.verify( + body, + { "x-webhooker-signature": sig, "x-webhooker-timestamp": String(now) }, + env, + ); + // Timestamp is present but nonce missing, so replay path is skipped and the + // body-only signature is used → verified. + expect(ok).toBe(true); + }); +}); diff --git a/tests/idempotency.test.ts b/tests/idempotency.test.ts new file mode 100644 index 0000000..618bfbe --- /dev/null +++ b/tests/idempotency.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "bun:test"; +import { deliveryKey, kvIdempotencyStore } from "../server/lib/lib/idempotency"; + +function createMockKV(): KVNamespace { + const store = new Map(); + const ttl = new Map(); + return { + get: async (key: string) => (store.has(key) ? store.get(key)! : null), + put: async (key: string, value: string, opts?: { expirationTtl?: number }) => { + store.set(key, value); + if (opts?.expirationTtl) ttl.set(key, opts.expirationTtl); + }, + delete: async (key: string) => { + store.delete(key); + }, + list: async () => ({ keys: [], list_complete: true, cacheStatus: null }), + } as unknown as KVNamespace; +} + +describe("deliveryKey", () => { + it("includes provider, tenant and delivery id", () => { + expect(deliveryKey("github", "team-a", "deliv-1")).toBe("delivery:github:team-a:deliv-1"); + }); + + it("falls back to 'global' without a tenant", () => { + expect(deliveryKey("github", undefined, "deliv-1")).toBe("delivery:github:global:deliv-1"); + }); +}); + +describe("kvIdempotencyStore", () => { + it("claims a key once and reports it thereafter", async () => { + const kv = createMockKV(); + const store = kvIdempotencyStore(kv); + expect(await store.has("k")).toBe(false); + expect(await store.claim("k", 300)).toBe(true); + expect(await store.has("k")).toBe(true); + expect(await store.claim("k", 300)).toBe(false); + }); + + it("stores the claimed key with the requested TTL", async () => { + const kv = createMockKV(); + let ttl = 0; + (kv.put as unknown) = async ( + key: string, + value: string, + opts?: { expirationTtl?: number }, + ): Promise => { + ttl = opts?.expirationTtl ?? 0; + }; + const store = kvIdempotencyStore(kv); + await store.claim("k", 600); + expect(ttl).toBe(600); + }); + + it("treats distinct keys independently", async () => { + const store = kvIdempotencyStore(createMockKV()); + expect(await store.claim("a", 300)).toBe(true); + expect(await store.claim("b", 300)).toBe(true); + expect(await store.claim("a", 300)).toBe(false); + }); +}); diff --git a/tests/webhook-tenant.test.ts b/tests/webhook-tenant.test.ts index 3e77e9f..c73a528 100644 --- a/tests/webhook-tenant.test.ts +++ b/tests/webhook-tenant.test.ts @@ -198,7 +198,7 @@ describe("processWebhook", () => { const second = await processWebhook(env, body, headers, makeWait().waitUntil, "team-a"); expect(first.status).toBe(200); expect(second.status).toBe(200); - expect(second.body).toEqual({ ok: true, duplicate: true }); + expect(second.body).toEqual({ ok: true, duplicate: true, requestId: expect.any(String) }); expect(fetched).toHaveLength(1); });