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,528 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@kan/db/repository/webhook.repo", () => ({
getActiveByWorkspaceId: vi.fn(),
}));
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import {
sendWebhookToUrl,
sendWebhooksForWorkspace,
createCardWebhookPayload,
webhookUrlSchema,
type WebhookPayload,
} from "./webhook";
const mockGetActiveByWorkspaceId = webhookRepo.getActiveByWorkspaceId as ReturnType<typeof vi.fn>;
describe("webhook utilities", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-01-15T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
describe("createCardWebhookPayload", () => {
it("creates a payload with required card fields", () => {
const payload = createCardWebhookPayload(
"card.created",
{
id: "card-123",
title: "Test Card",
listId: "list-456",
},
{
boardId: "board-789",
},
);
expect(payload.event).toBe("card.created");
expect(payload.timestamp).toBe("2024-01-15T12:00:00.000Z");
expect(payload.data.card).toEqual({
id: "card-123",
title: "Test Card",
description: undefined,
dueDate: null,
listId: "list-456",
boardId: "board-789",
});
});
it("includes optional card fields when provided", () => {
const dueDate = new Date("2024-02-01T10:00:00.000Z");
const payload = createCardWebhookPayload(
"card.updated",
{
id: "card-123",
title: "Test Card",
description: "A description",
dueDate,
listId: "list-456",
},
{
boardId: "board-789",
},
);
expect(payload.data.card.description).toBe("A description");
expect(payload.data.card.dueDate).toBe("2024-02-01T10:00:00.000Z");
});
it("includes board context when provided", () => {
const payload = createCardWebhookPayload(
"card.created",
{
id: "card-123",
title: "Test Card",
listId: "list-456",
},
{
boardId: "board-789",
boardName: "My Board",
},
);
expect(payload.data.board).toEqual({
id: "board-789",
name: "My Board",
});
});
it("includes list context when provided", () => {
const payload = createCardWebhookPayload(
"card.created",
{
id: "card-123",
title: "Test Card",
listId: "list-456",
},
{
boardId: "board-789",
listName: "To Do",
},
);
expect(payload.data.list).toEqual({
id: "list-456",
name: "To Do",
});
});
it("includes user context when provided", () => {
const payload = createCardWebhookPayload(
"card.created",
{
id: "card-123",
title: "Test Card",
listId: "list-456",
},
{
boardId: "board-789",
user: {
id: "user-111",
name: "John Doe",
},
},
);
expect(payload.data.user).toEqual({
id: "user-111",
name: "John Doe",
});
});
it("includes changes for card.updated events", () => {
const payload = createCardWebhookPayload(
"card.updated",
{
id: "card-123",
title: "Updated Title",
listId: "list-456",
},
{
boardId: "board-789",
changes: {
title: { from: "Old Title", to: "Updated Title" },
},
},
);
expect(payload.data.changes).toEqual({
title: { from: "Old Title", to: "Updated Title" },
});
});
});
describe("sendWebhookToUrl", () => {
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
});
const mockPayload: WebhookPayload = {
event: "card.created",
timestamp: "2024-01-15T12:00:00.000Z",
data: {
card: {
id: "card-123",
title: "Test Card",
listId: "list-456",
boardId: "board-789",
},
},
};
describe("SSRF protection", () => {
it("blocks HTTP URLs", async () => {
const result = await sendWebhookToUrl("http://example.com/webhook", undefined, mockPayload);
expect(result.success).toBe(false);
expect(result.error).toContain("HTTPS");
expect(global.fetch).not.toHaveBeenCalled();
});
it("blocks localhost", async () => {
const result = await sendWebhookToUrl("https://localhost/webhook", undefined, mockPayload);
expect(result.success).toBe(false);
expect(result.error).toContain("Localhost");
expect(global.fetch).not.toHaveBeenCalled();
});
it("blocks 127.0.0.1", async () => {
const result = await sendWebhookToUrl("https://127.0.0.1/webhook", undefined, mockPayload);
expect(result.success).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
it("blocks cloud metadata endpoint", async () => {
const result = await sendWebhookToUrl("https://169.254.169.254/latest/meta-data/", undefined, mockPayload);
expect(result.success).toBe(false);
expect(result.error).toContain("metadata");
expect(global.fetch).not.toHaveBeenCalled();
});
it("blocks private 10.x.x.x IPs", async () => {
const result = await sendWebhookToUrl("https://10.0.0.1/webhook", undefined, mockPayload);
expect(result.success).toBe(false);
expect(result.error).toContain("Private");
expect(global.fetch).not.toHaveBeenCalled();
});
it("blocks private 192.168.x.x IPs", async () => {
const result = await sendWebhookToUrl("https://192.168.1.1/webhook", undefined, mockPayload);
expect(result.success).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
it("allows valid HTTPS URLs", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, status: 200 });
const result = await sendWebhookToUrl("https://example.com/webhook", undefined, mockPayload);
expect(result.success).toBe(true);
expect(global.fetch).toHaveBeenCalled();
});
});
it("sends POST request with correct headers", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
await sendWebhookToUrl("https://example.com/webhook", undefined, mockPayload);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
"X-Webhook-Event": "card.created",
"X-Webhook-Timestamp": "2024-01-15T12:00:00.000Z",
}),
body: JSON.stringify(mockPayload),
}),
);
});
it("includes signature header when secret is provided", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
await sendWebhookToUrl("https://example.com/webhook", "my-secret", mockPayload);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({
headers: expect.objectContaining({
"X-Webhook-Signature": expect.stringMatching(/^[a-f0-9]{64}$/),
}),
}),
);
});
it("returns success for 2xx responses", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
const result = await sendWebhookToUrl(
"https://example.com/webhook",
undefined,
mockPayload,
);
expect(result).toEqual({ success: true, statusCode: 200 });
});
it("returns failure for non-2xx responses", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
const result = await sendWebhookToUrl(
"https://example.com/webhook",
undefined,
mockPayload,
);
expect(result).toEqual({
success: false,
statusCode: 500,
error: "500 Internal Server Error",
});
});
it("returns failure on network error", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Network error"),
);
const result = await sendWebhookToUrl(
"https://example.com/webhook",
undefined,
mockPayload,
);
expect(result).toEqual({
success: false,
error: "Network error",
});
});
it("returns timeout error when request takes too long", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockImplementationOnce(
() =>
new Promise((_, reject) => {
setTimeout(() => {
const error = new Error("Aborted");
error.name = "AbortError";
reject(error);
}, 15000);
}),
);
const resultPromise = sendWebhookToUrl(
"https://example.com/webhook",
undefined,
mockPayload,
);
// Advance timers to trigger the abort
vi.advanceTimersByTime(15000);
const result = await resultPromise;
expect(result).toEqual({
success: false,
error: "Request timed out",
});
});
});
describe("sendWebhooksForWorkspace", () => {
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
});
const mockDb = {} as Parameters<typeof sendWebhooksForWorkspace>[0];
const mockPayload: WebhookPayload = {
event: "card.created",
timestamp: "2024-01-15T12:00:00.000Z",
data: {
card: {
id: "card-123",
title: "Test Card",
listId: "list-456",
boardId: "board-789",
},
},
};
it("sends to all active webhooks subscribed to the event", async () => {
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
{
id: 1,
publicId: "wh-1",
url: "https://example.com/webhook1",
secret: "secret1",
events: ["card.created", "card.updated"],
active: true,
},
{
id: 2,
publicId: "wh-2",
url: "https://example.com/webhook2",
secret: null,
events: ["card.created"],
active: true,
},
]);
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
});
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
// Event filtering now happens at DB level
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
mockDb,
1,
"card.created",
);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/webhook1",
expect.any(Object),
);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/webhook2",
expect.any(Object),
);
});
it("does not send when no webhooks match the event (DB-level filtering)", async () => {
// DB-level event filter returns empty array when no webhooks match
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
mockDb,
1,
"card.created",
);
expect(global.fetch).not.toHaveBeenCalled();
});
it("continues sending to other webhooks when one fails", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
{
id: 1,
publicId: "wh-1",
url: "https://example.com/webhook1",
secret: null,
events: ["card.created"],
active: true,
},
{
id: 2,
publicId: "wh-2",
url: "https://example.com/webhook2",
secret: null,
events: ["card.created"],
active: true,
},
]);
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Error" })
.mockResolvedValueOnce({ ok: true, status: 200 });
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Webhook delivery failed"),
);
consoleSpy.mockRestore();
});
it("handles empty webhook list", async () => {
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
expect(global.fetch).not.toHaveBeenCalled();
});
it("catches and logs DB errors without throwing", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mockGetActiveByWorkspaceId.mockRejectedValueOnce(
new Error("DB connection failed"),
);
// Should not throw — the try/catch absorbs the error
await expect(
sendWebhooksForWorkspace(mockDb, 1, mockPayload),
).resolves.toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to send webhooks for workspace:",
expect.any(Error),
);
consoleSpy.mockRestore();
});
});
describe("webhookUrlSchema", () => {
it("accepts valid HTTPS URLs", () => {
expect(webhookUrlSchema.safeParse("https://example.com/webhook").success).toBe(true);
});
it("rejects HTTP URLs", () => {
const result = webhookUrlSchema.safeParse("http://example.com/webhook");
expect(result.success).toBe(false);
});
it("rejects localhost", () => {
const result = webhookUrlSchema.safeParse("https://localhost/webhook");
expect(result.success).toBe(false);
});
it("rejects private IPs", () => {
expect(webhookUrlSchema.safeParse("https://10.0.0.1/webhook").success).toBe(false);
expect(webhookUrlSchema.safeParse("https://192.168.1.1/webhook").success).toBe(false);
});
it("rejects cloud metadata endpoints", () => {
expect(webhookUrlSchema.safeParse("https://169.254.169.254/latest").success).toBe(false);
});
});
});