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:
RhenCloud 2026-08-15 14:39:41 +08:00
parent 92ba2203a5
commit 737dd7af98
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
15 changed files with 378 additions and 26 deletions

View file

@ -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 {