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