f8550110eb
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>
153 lines
4.0 KiB
TypeScript
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");
|
|
}
|