Files
Grow/src/lib/webhooks.ts
T
arnaudne 1dac9690f5 security: SSRF prevention, ownership gaps, host header injection, date validation
- webhooks: block SSRF — reject localhost/127.x/169.254.x/10.x/192.168.x
  URLs at creation and silently skip at dispatch time
- medication-profiles/last-intakes: add family ownership check on babyId
- invite/send-email: build invite URL from NEXTAUTH_URL env only;
  drop req.headers.get("origin") to prevent host header injection
- baby POST: validate birthDate is a real date; add required-fields check
- milk POST: validate storedAt before new Date()
- journal GET: validate date filter before new Date()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:33:45 +02:00

58 lines
1.5 KiB
TypeScript

import { createHmac, randomBytes } from "crypto";
import { prisma } from "./prisma";
const PRIVATE_IP_RE =
/^(localhost|127\.|0\.0\.0\.0|::1|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/i;
function isSafeWebhookUrl(raw: string): boolean {
try {
const { protocol, hostname } = new URL(raw);
if (protocol !== "https:" && protocol !== "http:") return false;
if (PRIVATE_IP_RE.test(hostname)) return false;
return true;
} catch {
return false;
}
}
export type WebhookEventType =
| "event.created"
| "growth.created"
| "doctor_note.created"
| "milestone.created"
| "medication.due";
export function generateWebhookSecret(): string {
return randomBytes(32).toString("hex");
}
export async function dispatchWebhook(
familyId: string,
eventType: WebhookEventType,
payload: Record<string, unknown>
): Promise<void> {
const hooks = await prisma.webhook.findMany({
where: { familyId, active: true, events: { has: eventType } },
});
if (hooks.length === 0) return;
const body = JSON.stringify({ event: eventType, ...payload });
await Promise.allSettled(
hooks.map(async (hook) => {
if (!isSafeWebhookUrl(hook.url)) return;
const sig = createHmac("sha256", hook.secret).update(body).digest("hex");
await fetch(hook.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Grow-Signature": `sha256=${sig}`,
"X-Grow-Event": eventType,
},
body,
signal: AbortSignal.timeout(10_000),
});
})
);
}