import { env } from "cloudflare:workers"; import { recordWebhookDelivery } from "../webhook-log"; import { repoDb } from "../scoped-db"; import { decryptSecret, encryptSecret, hmacSha256Hex, randomId } from "../crypto"; import { badRequest } from "../errors"; import { assertOutboundUrl, isAllowedLoopback, isSuccess, outboundFetch } from "../integrations/outbound"; import { assertResolvesPublic } from "../net/resolve-guard"; import type { WebhookEvent } from "~/lib/webhook-events"; /** Envelope marker for secrets encrypted with the SECRETS_KEY AES-GCM wrapper; anything else is a legacy plaintext row. */ const SEALED_PREFIX = "enc:"; const WEBHOOK_SEAL = { purpose: "webhook-secret" }; const MAX_STORED_REQUEST = 64 * 1024; export const MIN_WEBHOOK_SECRET = 8; export const MAX_WEBHOOK_SECRET = 200; export type LegacyWebhook = { id: string; repo_id: string; url: string; secret: string | null }; function secretsKey(): string { if (!env.SECRETS_KEY) throw badRequest("SECRETS_KEY is not configured on this instance"); return env.SECRETS_KEY; } /** Validates a legacy repo webhook target: public https (or http when the operator opted in), no userinfo or IP literals. */ export function assertWebhookUrl(raw: string): URL { return assertOutboundUrl(raw, undefined, true); } export async function assertWebhookTarget(raw: string): Promise { const url = assertWebhookUrl(raw); if (!isAllowedLoopback(url)) await assertResolvesPublic(url); return url; } export async function sealWebhookSecret(secret: string | null): Promise { if (secret === null) return null; const sealed = await encryptSecret(secretsKey(), secret, WEBHOOK_SEAL); return `${SEALED_PREFIX}${sealed.iv}:${sealed.ciphertext}`; } export function isSealedSecret(stored: string | null): boolean { return stored !== null && stored.startsWith(SEALED_PREFIX); } /** * Returns the signing secret. Rows written before secrets were encrypted are re-sealed in place the first * time they are read, so the plaintext disappears from the database without a bulk migration. */ export async function openWebhookSecret( hook: Pick, ): Promise { if (hook.secret === null || hook.secret === "") return null; if (!isSealedSecret(hook.secret)) { const plaintext = hook.secret; const sealed = await sealWebhookSecret(plaintext); await repoDb(hook.repo_id).run( "UPDATE webhooks SET secret = ? WHERE id = ? AND secret = ?", sealed, hook.id, plaintext, ); return plaintext; } const envelope = hook.secret.slice(SEALED_PREFIX.length); const separator = envelope.indexOf(":"); if (separator <= 0 || separator === envelope.length - 1) return null; const iv = envelope.slice(0, separator); const ciphertext = envelope.slice(separator + 1); return decryptSecret(secretsKey(), ciphertext, iv, WEBHOOK_SEAL); } /** Record kept for the delivery log: never the response body (HOOK-1), request body only when it fits. */ async function recordDelivery(input: { hook: LegacyWebhook; event: string; status: number | null; error: string | null; body: string; durationMs: number; attempt: number; }): Promise { const stored = input.body.length <= MAX_STORED_REQUEST ? input.body : JSON.stringify({ truncated: true, event: input.event }); await recordWebhookDelivery(input.hook.repo_id, { id: randomId(), webhook_id: input.hook.id, event: input.event, status_code: input.status, error: input.error, request_body: stored, duration_ms: input.durationMs, attempt: input.attempt, created_at: Date.now(), }); } /** Delivers one legacy repo webhook through the SSRF-guarded outbound client (DNS check, no redirects, size caps). */ export async function deliverWebhook( hook: LegacyWebhook, event: WebhookEvent, deliveryId: string, payload: Record, attempt: number, ): Promise { const body = JSON.stringify(payload); const headers: Record = { "content-type": "application/json", "user-agent": "gitfor-hookshot", "x-gitfor-event": event, "x-gitfor-delivery": deliveryId, "x-github-event": event, "x-github-delivery": deliveryId, }; const secret = await openWebhookSecret(hook); if (secret) { const signature = `sha256=${await hmacSha256Hex(secret, body)}`; headers["x-gitfor-signature-256"] = signature; headers["x-hub-signature-256"] = signature; } const result = await outboundFetch(hook.url, { method: "POST", headers, body, allowHttp: true }, 0); await recordDelivery({ hook, event, status: result.status, error: result.error, body, durationMs: result.durationMs, attempt, }); return isSuccess(result); } /** Replays a stored delivery; truncated bodies were never stored and cannot be resent. */ export function replayableBody(requestBody: string): Record { const parsed: unknown = JSON.parse(requestBody); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw badRequest("Delivery cannot be replayed"); if ((parsed as { truncated?: unknown }).truncated === true) { throw badRequest("This delivery was too large to store and cannot be replayed"); } return parsed as Record; }