feat(api): add webhook delivery utility and card event integration (#392)

* feat(api): add webhook delivery utility and card event integration

Add the core webhook delivery logic and wire it into card mutations:

- Add sendWebhookToUrl() with HMAC-SHA256 signing, 10s timeout
- Add sendWebhooksForWorkspace() for fan-out delivery (fire-and-forget)
- Add createCardWebhookPayload() for building webhook payloads
- Fire webhooks on card create, update, move, and delete events
- Add unit tests for webhook utility functions

Depends on #391 (DB schema & repository).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): use correct boardId in webhook payloads and add rejection safety

- Fix bug where workspaceId was incorrectly passed as boardId in all
  webhook payloads — now uses board's publicId via boardPublicId
- Replace void sendWebhooksForWorkspace() with .catch() to prevent
  unhandled promise rejections if the DB query inside fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): add SSRF protection to webhook delivery

Block webhook URLs targeting internal networks:
- Require HTTPS (reject HTTP)
- Block localhost, 127.0.0.1, ::1, 0.0.0.0
- Block cloud metadata endpoints (169.254.169.254, metadata.google.internal)
- Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
- Add tests for all blocked URL patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): use WebhookEvent type from schema instead of duplicating

Replace the hardcoded WebhookEventType union with the canonical
WebhookEvent type from @kan/db/schema, addressing reviewer feedback
on PR #392.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): improve webhook delivery safety and validation

Cherry-pick delivery-related changes from b2cc9ac:
- Extract URL validation into reusable webhookUrlSchema zod validator
  for SSRF checks
- Wrap sendWebhooksForWorkspace in try/catch to prevent unhandled
  promise rejections
- Document SSRF risk mitigation on sendWebhookToUrl
- Add corresponding tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nick Meinhold
2026-02-28 00:15:35 +11:00
committed by GitHub
parent 93f2816b37
commit bd25fb33f7
3 changed files with 899 additions and 0 deletions

View File

@@ -0,0 +1,257 @@
import crypto from "crypto";
import { z } from "zod";
import type { dbClient } from "@kan/db/client";
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import type { WebhookEvent } from "@kan/db/schema";
export type WebhookEventType = WebhookEvent;
export interface WebhookPayload {
event: WebhookEventType;
timestamp: string;
data: {
card: {
id: 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<string, { from: unknown; to: unknown }>;
};
}
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 = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
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<string, string> = {
"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<void> {
try {
// Get active webhooks for this workspace, pre-filtered by event at the DB level
const webhooks = await webhookRepo.getActiveByWorkspaceId(
db,
workspaceId,
payload.event,
);
// Send to all webhooks in parallel (fire and forget)
const promises = webhooks.map((webhook) =>
sendWebhookToUrl(webhook.url, webhook.secret ?? undefined, payload).then(
(result) => {
if (!result.success) {
console.error(
`Webhook delivery failed to ${webhook.url}: ${result.error}`,
);
}
},
),
);
// Wait for all to complete but don't block on failures
await Promise.allSettled(promises);
} catch (error) {
console.error("Failed to send webhooks for workspace:", error);
}
}
export function createCardWebhookPayload(
event: WebhookEventType,
card: {
id: 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<string, { from: unknown; to: unknown }>;
},
): WebhookPayload {
return {
event,
timestamp: new Date().toISOString(),
data: {
card: {
id: card.id,
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,
},
};
}