From 1dac9690f522c69d6d2367944e35ed7a2bc45f17 Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Mon, 15 Jun 2026 23:33:45 +0200 Subject: [PATCH] security: SSRF prevention, ownership gaps, host header injection, date validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- SECURITY_AUDIT.md | 50 +++++++++++++++++-- src/app/api/baby/route.ts | 3 ++ src/app/api/invite/send-email/route.ts | 2 +- src/app/api/journal/route.ts | 4 ++ .../medication-profiles/last-intakes/route.ts | 5 ++ src/app/api/milk/route.ts | 4 ++ src/app/api/webhooks/route.ts | 13 ++++- src/lib/webhooks.ts | 15 ++++++ 8 files changed, 91 insertions(+), 5 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 734dd13..bb81095 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -11,11 +11,11 @@ | Severity | Found | Fixed | |----------|-------|-------| | CRITICAL | 9 | 9 | -| HIGH | 11 | 10 | -| MEDIUM | 7 | 5 | +| HIGH | 13 | 12 | +| MEDIUM | 10 | 8 | | LOW | 1 | 0 | -_Updated after second-pass audit (pass 2 of 2)._ +_Updated after third-pass audit (pass 3 of 3)._ --- @@ -209,6 +209,50 @@ If `SUPERADMIN_EMAIL` env var is not set, the condition `SUPERADMIN_EMAIL && ses --- +--- + +## HIGH — Fixed (pass 3) + +### 25. SSRF via webhook URL — internal IP/localhost allowed +**Affected:** `webhooks/route.ts` POST (creation) and `lib/webhooks.ts` dispatch + +**Problem:** Webhook URLs were accepted without hostname validation. An attacker could register `http://localhost:5432` or `http://169.254.169.254/latest/meta-data/` (AWS metadata endpoint) and cause the server to make requests to internal services, leaking credentials or probing the internal network. + +**Fix:** Added `isSafeWebhookUrl()` helper using regex to reject `localhost`, `127.x`, `0.0.0.0`, `::1`, `10.x`, `172.16–31.x`, `192.168.x`, `169.254.x`. Applied at both creation (returns 400) and dispatch (skips the hook silently). + +--- + +### 26. `medication-profiles/last-intakes` — missing family ownership check +**Problem:** Accepted any `babyId` without verifying it belongs to the session user's family. Cross-family medication data leak. + +**Fix:** Added `prisma.baby.findFirst({ where: { id: babyId, familyId } })` check. + +--- + +## MEDIUM — Fixed (pass 3) + +### 27. Host header injection in invite email URL +**Affected:** `invite/send-email/route.ts` + +**Problem:** Used `req.headers.get("origin")` to build the invite URL, allowing an attacker to craft a request with `Origin: https://evil.com` and send phishing links to invited users. + +**Fix:** Now uses `process.env.NEXTAUTH_URL` exclusively; request Origin header ignored. + +--- + +### 28. `baby/route.ts` POST — birthDate not validated +**Problem:** `new Date(birthDate)` accepted invalid strings silently; also no required-fields check. + +**Fix:** Added `!name || !birthDate` check + `isNaN` date validation. + +--- + +### 29. `milk/route.ts` POST — storedAt not validated +### 30. `journal/route.ts` GET — date filter not validated +**Fix:** Added `isNaN(new Date(x).getTime())` guards on both. + +--- + ## MEDIUM — Not applicable (already handled) ### Upload route — extension from MIME type, not filename diff --git a/src/app/api/baby/route.ts b/src/app/api/baby/route.ts index 22bbaa4..d8e9965 100644 --- a/src/app/api/baby/route.ts +++ b/src/app/api/baby/route.ts @@ -27,6 +27,9 @@ export async function POST(req: Request) { const { name, birthDate, gender } = await req.json(); + if (!name || !birthDate) return NextResponse.json({ error: "name et birthDate requis" }, { status: 400 }); + if (isNaN(new Date(birthDate).getTime())) return NextResponse.json({ error: "Date invalide" }, { status: 400 }); + const user = await prisma.user.findUnique({ where: { id: session.user.id } }); if (!user?.familyId) return NextResponse.json({ error: "Aucune famille" }, { status: 400 }); diff --git a/src/app/api/invite/send-email/route.ts b/src/app/api/invite/send-email/route.ts index a0bafb8..ec22eef 100644 --- a/src/app/api/invite/send-email/route.ts +++ b/src/app/api/invite/send-email/route.ts @@ -34,7 +34,7 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Réservé aux parents" }, { status: 403 }); } - const origin = req.headers.get("origin") ?? process.env.NEXTAUTH_URL ?? ""; + const origin = (process.env.NEXTAUTH_URL ?? "").replace(/\/$/, ""); const assignedRole = role as Role; const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000); diff --git a/src/app/api/journal/route.ts b/src/app/api/journal/route.ts index 5ca8b1b..93cfe67 100644 --- a/src/app/api/journal/route.ts +++ b/src/app/api/journal/route.ts @@ -17,6 +17,10 @@ export async function GET(req: Request) { const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + if (date && isNaN(new Date(date).getTime())) { + return NextResponse.json({ error: "Date invalide" }, { status: 400 }); + } + const where: Record = { babyId }; if (date) { diff --git a/src/app/api/medication-profiles/last-intakes/route.ts b/src/app/api/medication-profiles/last-intakes/route.ts index 7f99069..be68c8d 100644 --- a/src/app/api/medication-profiles/last-intakes/route.ts +++ b/src/app/api/medication-profiles/last-intakes/route.ts @@ -12,6 +12,11 @@ export async function GET(req: Request) { const babyId = searchParams.get("babyId"); if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + // Fetch last 100 medication events — enough to find latest per med const events = await prisma.event.findMany({ where: { babyId, type: "MEDICATION" }, diff --git a/src/app/api/milk/route.ts b/src/app/api/milk/route.ts index 0af99d7..4ed9513 100644 --- a/src/app/api/milk/route.ts +++ b/src/app/api/milk/route.ts @@ -46,6 +46,10 @@ export async function POST(req: Request) { const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } }); if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + if (storedAt && isNaN(new Date(storedAt).getTime())) { + return NextResponse.json({ error: "Date invalide" }, { status: 400 }); + } + const loc = location ?? "fridge"; const storedDate = storedAt ? new Date(storedAt) : new Date(); const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge; diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts index e046792..447285e 100644 --- a/src/app/api/webhooks/route.ts +++ b/src/app/api/webhooks/route.ts @@ -3,6 +3,17 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { generateWebhookSecret } from "@/lib/webhooks"; +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 isSafeUrl(raw: string): boolean { + try { + const { protocol, hostname } = new URL(raw); + if (protocol !== "https:" && protocol !== "http:") return false; + return !PRIVATE_IP_RE.test(hostname); + } catch { return false; } +} + export async function GET() { const session = await auth(); const familyId = (session?.user as { familyId?: string } | undefined)?.familyId; @@ -25,7 +36,7 @@ export async function POST(req: Request) { const { url, events } = await req.json(); if (!url || !events?.length) return NextResponse.json({ error: "url et events requis" }, { status: 400 }); - try { new URL(url); } catch { return NextResponse.json({ error: "URL invalide" }, { status: 400 }); } + if (!isSafeUrl(url)) return NextResponse.json({ error: "URL invalide ou non autorisée" }, { status: 400 }); const secret = generateWebhookSecret(); diff --git a/src/lib/webhooks.ts b/src/lib/webhooks.ts index 276830f..b1ad394 100644 --- a/src/lib/webhooks.ts +++ b/src/lib/webhooks.ts @@ -1,6 +1,20 @@ 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" @@ -26,6 +40,7 @@ export async function dispatchWebhook( 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",