import crypto from "crypto"; import { z } from "zod"; import type { dbClient } from "@kan/db/client"; import type { WebhookEvent } from "@kan/db/schema"; import * as webhookRepo from "@kan/db/repository/webhook.repo"; import { createLogger } from "@kan/logger"; const log = createLogger("webhook"); export type WebhookEventType = WebhookEvent; export interface WebhookPayload { event: WebhookEventType; timestamp: string; data: { card: { id: string; publicId: string; title: string; description?: string | null; dueDate?: string | null; // ISO string after JSON serialization listId: string; boardId: string; }; board?: { id: string; name: string; }; list?: { id: string; name: string; }; user?: { id: string; name: string | null; }; changes?: Record; }; } function generateSignature(payload: string, secret: string): string { return crypto.createHmac("sha256", secret).update(payload).digest("hex"); } /** * Zod schema for webhook URLs with SSRF mitigation. * Requires HTTPS and blocks private/internal IP ranges, localhost, * and cloud metadata endpoints. */ export const webhookUrlSchema = z .string() .url() .max(2048) .refine( (url) => { try { return new URL(url).protocol === "https:"; } catch { return false; } }, { message: "Only HTTPS URLs are allowed" }, ) .refine( (url) => { try { const hostname = new URL(url).hostname.toLowerCase(); return !( hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0" ); } catch { return false; } }, { message: "Localhost URLs are not allowed" }, ) .refine( (url) => { try { const hostname = new URL(url).hostname.toLowerCase(); return !( hostname === "169.254.169.254" || hostname === "metadata.google.internal" ); } catch { return false; } }, { message: "Cloud metadata endpoints are not allowed" }, ) .refine( (url) => { try { const hostname = new URL(url).hostname.toLowerCase(); const ipv4Match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(hostname); if (ipv4Match) { const [, a, b] = ipv4Match.map(Number); if ( a === 10 || (a === 172 && b! >= 16 && b! <= 31) || (a === 192 && b === 168) ) { return false; } } return true; } catch { return false; } }, { message: "Private IP addresses are not allowed" }, ); /** * Send a webhook payload to a specific URL. * * SSRF note: URL validation (HTTPS-only, no private IPs, no cloud metadata) * is enforced both here at delivery time and at webhook creation via * webhookUrlSchema. This function is only reachable by workspace admins. */ export async function sendWebhookToUrl( url: string, secret: string | undefined, payload: WebhookPayload, ): Promise<{ success: boolean; statusCode?: number; error?: string }> { const result = webhookUrlSchema.safeParse(url); if (!result.success) { return { success: false, error: result.error.issues[0]?.message }; } const body = JSON.stringify(payload); const headers: Record = { "Content-Type": "application/json", "X-Webhook-Event": payload.event, "X-Webhook-Timestamp": payload.timestamp, }; if (secret) { headers["X-Webhook-Signature"] = generateSignature(body, secret); } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); try { const response = await fetch(url, { method: "POST", headers, body, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { return { success: false, statusCode: response.status, error: `${response.status} ${response.statusText}`, }; } return { success: true, statusCode: response.status }; } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === "AbortError") { return { success: false, error: "Request timed out" }; } return { success: false, error: error instanceof Error ? error.message : "Unknown error", }; } } /** * Send webhook to all active webhooks for a workspace that are subscribed to the event. * Wrapped in try/catch so callers using fire-and-forget don't risk unhandled rejections. */ export async function sendWebhooksForWorkspace( db: dbClient, workspaceId: number, payload: WebhookPayload, ): Promise { try { // Get active webhooks for this workspace and filter by event client-side const allWebhooks = await webhookRepo.getActiveByWorkspaceId( db, workspaceId, ); const webhooksForEvent = allWebhooks.filter((w) => w.events.includes(payload.event), ); // Send to all subscribed webhooks in parallel (fire and forget) const promises = webhooksForEvent.map((webhook) => sendWebhookToUrl(webhook.url, webhook.secret ?? undefined, payload).then( (result) => { if (!result.success) { log.error({ url: webhook.url, event: payload.event, error: result.error, statusCode: result.statusCode }, "Webhook delivery failed"); } else { log.info({ url: webhook.url, event: payload.event, statusCode: result.statusCode }, "Webhook delivered"); } }, ), ); // Wait for all to complete but don't block on failures await Promise.allSettled(promises); } catch (error) { log.error({ err: error, workspaceId }, "Failed to send webhooks for workspace"); } } export function createCardWebhookPayload( event: WebhookEventType, card: { id: string; publicId: string; title: string; description?: string | null; dueDate?: Date | null; listId: string; }, context: { boardId: string; boardName?: string; listName?: string; user?: { id: string; name: string | null; }; changes?: Record; }, ): WebhookPayload { return { event, timestamp: new Date().toISOString(), data: { card: { id: card.id, publicId: card.publicId, title: card.title, description: card.description, dueDate: card.dueDate?.toISOString() ?? null, listId: card.listId, boardId: context.boardId, }, board: context.boardName ? { id: context.boardId, name: context.boardName } : undefined, list: context.listName ? { id: card.listId, name: context.listName } : undefined, user: context.user, changes: context.changes, }, }; }