1dac9690f5
- 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>
58 lines
1.5 KiB
TypeScript
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),
|
|
});
|
|
})
|
|
);
|
|
}
|