Files
CapaKraken/packages/api/src/lib/webhook-dispatcher.ts
T
Hartmut f8550110eb security: fix 4 OWASP quick-wins from audit round 2
A04-1 (High): docker-compose E2E_TEST_MODE now defaults to "false"
  via ${E2E_TEST_MODE:-false} — prevents accidental security bypass in
  non-test deployments. runtime-env.ts throws at startup if
  E2E_TEST_MODE=true in production.

A05-3 (Medium): all 4 cron routes now fail-closed when CRON_SECRET
  is unset. Extracted shared verifyCronSecret() helper to
  apps/web/src/lib/cron-auth.ts.

A02-1 (Low): verifyCronSecret uses crypto.timingSafeEqual for
  constant-time Bearer token comparison.

A10-1 (Medium): Slack webhook routing uses strict hostname check
  (parsedUrl.hostname === "hooks.slack.com") instead of .includes()
  to prevent bypass via subdomain confusion.

Tickets created for remaining findings: #28 (TOTP rate limit),
#29 (allocations role check), #30 (API keys in DB), #31 (pgAdmin
creds), #32 (MFA enforcement), #33 (auth anomaly alerting),
#34 (comment server-side sanitization).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-01 22:57:51 +02:00

153 lines
4.0 KiB
TypeScript

/**
* Outbound webhook dispatcher.
*
* Fetches active webhooks matching a given event, sends POST requests
* with JSON payloads, and optionally signs them with HMAC-SHA256.
*
* Fire-and-forget — errors are logged, never thrown.
*/
import { createHmac } from "node:crypto";
import { logger } from "./logger.js";
import { sendSlackNotification } from "./slack-notify.js";
import { assertWebhookUrlAllowed } from "./ssrf-guard.js";
/** Available webhook event types. */
export const WEBHOOK_EVENTS = [
"allocation.created",
"allocation.updated",
"allocation.deleted",
"project.created",
"project.status_changed",
"vacation.approved",
"estimate.submitted",
"estimate.approved",
] as const;
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];
interface MinimalDb {
webhook: {
findMany: (args: {
where: { isActive: boolean; events: { has: string } };
}) => Promise<
Array<{
id: string;
name: string;
url: string;
secret: string | null;
events: string[];
}>
>;
};
}
/**
* Dispatch an event to all matching active webhooks.
* This is fire-and-forget: errors are logged and swallowed.
*/
export function dispatchWebhooks(
db: MinimalDb,
event: string,
payload: Record<string, unknown>,
): void {
void _dispatch(db, event, payload);
}
async function _dispatch(
db: MinimalDb,
event: string,
payload: Record<string, unknown>,
): Promise<void> {
try {
const webhooks = await db.webhook.findMany({
where: { isActive: true, events: { has: event } },
});
if (webhooks.length === 0) return;
const timestamp = new Date().toISOString();
const body = JSON.stringify({ event, timestamp, payload });
const promises = webhooks.map((wh) =>
_sendToWebhook(wh, event, body, timestamp, payload),
);
await Promise.allSettled(promises);
} catch (err) {
logger.error({ err, event }, "Failed to dispatch webhooks");
}
}
async function _sendToWebhook(
wh: { id: string; name: string; url: string; secret: string | null },
event: string,
body: string,
timestamp: string,
payload: Record<string, unknown>,
): Promise<void> {
try {
await assertWebhookUrlAllowed(wh.url);
// Slack-specific path: use the Slack notification helper.
// Use strict hostname match to prevent bypass via "hooks.slack.com.attacker.example.com".
const parsedUrl = new URL(wh.url);
if (parsedUrl.hostname === "hooks.slack.com") {
const message = formatSlackMessage(event, payload);
await sendSlackNotification(wh.url, message);
return;
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Webhook-Event": event,
"X-Webhook-Timestamp": timestamp,
};
if (wh.secret) {
const signature = createHmac("sha256", wh.secret)
.update(body)
.digest("hex");
headers["X-Webhook-Signature"] = signature;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5_000);
try {
const response = await fetch(wh.url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Webhook responded with HTTP ${response.status}`);
}
} finally {
clearTimeout(timeout);
}
} catch (err) {
logger.warn(
{ err, event, webhookId: wh.id, webhookName: wh.name, webhookUrl: wh.url },
"Webhook delivery failed",
);
}
}
/**
* Format a human-readable Slack message from a webhook event.
*/
function formatSlackMessage(
event: string,
payload: Record<string, unknown>,
): string {
const label = event.replace(/\./g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const id = (payload["id"] as string) ?? (payload["projectId"] as string) ?? "";
const name = (payload["name"] as string) ?? "";
const parts = [`*${label}*`];
if (name) parts.push(`\u2022 ${name}`);
if (id) parts.push(`ID: \`${id}\``);
return parts.join("\n");
}