fix(webhook): dedup duplicate deliveries, cache HMAC key, pass ExecutionContext

Skip re-processing when the same X-GitHub-Delivery id is seen (KV, 300s TTL)
to stop double pushes when both an org and an App webhook target the worker.
Pass ExecutionContext through so dispatch runs via waitUntil instead of a
fire-and-forget that gets frozen. Cache the imported HMAC key per secret.
This commit is contained in:
RhenCloud 2026-08-02 06:51:20 +08:00
parent c75cc058fc
commit befed0267a
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
3 changed files with 37 additions and 13 deletions

View file

@ -9,8 +9,8 @@ export { DiscordGateway };
const app = createServer();
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return app.fetch(request, env);
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return app.fetch(request, env, ctx);
},
async scheduled(_event: ScheduledEvent, env: Env): Promise<void> {

View file

@ -45,9 +45,21 @@ export function createServer(): Hono<{ Bindings: Env }> {
return c.json({ error: "Invalid event" }, 400);
}
const delivery = headers["x-github-delivery"];
if (delivery) {
const seen = await c.env.KV.get(`delivery:${delivery}`);
if (seen) {
return c.json({ ok: true, duplicate: true });
}
await c.env.KV.put(`delivery:${delivery}`, "1", { expirationTtl: 300 });
}
const { loadConfig } = await import("./config");
const config = await loadConfig(c.env);
dispatchEvent(config, event, c.env).catch((err) => log.error(err, "Dispatch failed"));
const dispatch = dispatchEvent(config, event, c.env).catch((err) =>
log.error(err, "Dispatch failed"),
);
c.executionCtx.waitUntil(dispatch);
return c.json({ ok: true });
});

View file

@ -1,5 +1,21 @@
import type { WebhookEvent, Route, Filter } from "./types";
const keyCache = new Map<string, CryptoKey>();
async function getHmacKey(secret: string): Promise<CryptoKey> {
const cached = keyCache.get(secret);
if (cached) return cached;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
keyCache.set(secret, key);
return key;
}
export async function verifySignature(
payload: string,
signature: string | undefined,
@ -8,13 +24,7 @@ export async function verifySignature(
if (!signature) return false;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const key = await getHmacKey(secret);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
const expected = `sha256=${Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
@ -79,7 +89,7 @@ function extractBranch(event: WebhookEvent): string | undefined {
return undefined;
}
function matchFilter(filter: Filter, event: WebhookEvent): boolean {
function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string): boolean {
let value: string | undefined;
switch (filter.type) {
@ -99,7 +109,7 @@ function matchFilter(filter: Filter, event: WebhookEvent): boolean {
value = extractBranch(event);
break;
case "keyword": {
const body = JSON.stringify(event.payload).toLowerCase();
const body = keywordBody ?? JSON.stringify(event.payload).toLowerCase();
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
return patterns.some((p) => {
try {
@ -123,5 +133,7 @@ function matchFilter(filter: Filter, event: WebhookEvent): boolean {
export function matchRoute(route: Route, event: WebhookEvent): boolean {
if (!route.enabled) return false;
return route.filters.every((f) => matchFilter(f, event));
const hasKeyword = route.filters.some((f) => f.type === "keyword");
const keywordBody = hasKeyword ? JSON.stringify(event.payload).toLowerCase() : undefined;
return route.filters.every((f) => matchFilter(f, event, keywordBody));
}