mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
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.
This commit is contained in:
parent
92ba2203a5
commit
737dd7af98
15 changed files with 378 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||
|
|
@ -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/`)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
- 按事件类型、仓库、操作人、操作、分支、关键词过滤(支持 `*`/`?` 通配符与 `/正则/`)
|
||||
|
|
|
|||
|
|
@ -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=<hex>` (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=<hex>` (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
|
||||
|
||||
|
|
|
|||
|
|
@ -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=<hmac-sha256 hex of the raw body>` 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
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ POST /webhook
|
|||
|
||||
### 自定义 Webhook
|
||||
|
||||
任意 JSON 载荷用 `X-WebHooker-Signature: sha256=<hex>`(对原始 body 的 HMAC-SHA256,使用分组或全局 secret)签名后即可成为 `custom` 事件。用 `event: custom` 过滤器的路由接收。载荷格式见[配置 → 自定义 Webhook](../guide/ingress.md#自定义-webhook)。
|
||||
任意 JSON 载荷用 `X-WebHooker-Signature: sha256=<hex>`(对原始 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 安装事件
|
||||
|
||||
|
|
|
|||
|
|
@ -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=<hex>` 签名。载荷会变成 `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
|
||||
|
|
|
|||
7
server/lib/lib/correlation.ts
Normal file
7
server/lib/lib/correlation.ts
Normal file
|
|
@ -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)}`;
|
||||
}
|
||||
39
server/lib/lib/idempotency.ts
Normal file
39
server/lib/lib/idempotency.ts
Normal file
|
|
@ -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<boolean>;
|
||||
claim(key: string, ttlSeconds: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
export function kvIdempotencyStore(kv: KVNamespace): IdempotencyStore {
|
||||
return {
|
||||
async has(key): Promise<boolean> {
|
||||
const value = await kv.get(key);
|
||||
return value !== null && value !== undefined;
|
||||
},
|
||||
async claim(key, ttlSeconds): Promise<boolean> {
|
||||
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}`;
|
||||
}
|
||||
|
|
@ -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<string, string>,
|
||||
secret: string,
|
||||
kv: KVNamespace,
|
||||
): Promise<boolean> {
|
||||
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=<hex>` 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 {
|
||||
|
|
|
|||
|
|
@ -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<unknown>) => void,
|
||||
tenantId?: string,
|
||||
): Promise<WebhookResult> {
|
||||
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`. */
|
||||
|
|
|
|||
126
tests/custom-replay.test.ts
Normal file
126
tests/custom-replay.test.ts
Normal file
|
|
@ -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<string, string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
61
tests/idempotency.test.ts
Normal file
61
tests/idempotency.test.ts
Normal file
|
|
@ -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<string, string>();
|
||||
const ttl = new Map<string, number>();
|
||||
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<void> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue