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>
This commit is contained in:
2026-06-15 23:33:45 +02:00
parent 432feed88f
commit 1dac9690f5
8 changed files with 91 additions and 5 deletions
+47 -3
View File
@@ -11,11 +11,11 @@
| Severity | Found | Fixed | | Severity | Found | Fixed |
|----------|-------|-------| |----------|-------|-------|
| CRITICAL | 9 | 9 | | CRITICAL | 9 | 9 |
| HIGH | 11 | 10 | | HIGH | 13 | 12 |
| MEDIUM | 7 | 5 | | MEDIUM | 10 | 8 |
| LOW | 1 | 0 | | 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.1631.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) ## MEDIUM — Not applicable (already handled)
### Upload route — extension from MIME type, not filename ### Upload route — extension from MIME type, not filename
+3
View File
@@ -27,6 +27,9 @@ export async function POST(req: Request) {
const { name, birthDate, gender } = await req.json(); 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 } }); const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.familyId) return NextResponse.json({ error: "Aucune famille" }, { status: 400 }); if (!user?.familyId) return NextResponse.json({ error: "Aucune famille" }, { status: 400 });
+1 -1
View File
@@ -34,7 +34,7 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Réservé aux parents" }, { status: 403 }); 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 assignedRole = role as Role;
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000); const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000);
+4
View File
@@ -17,6 +17,10 @@ export async function GET(req: Request) {
const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } });
if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); 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<string, unknown> = { babyId }; const where: Record<string, unknown> = { babyId };
if (date) { if (date) {
@@ -12,6 +12,11 @@ export async function GET(req: Request) {
const babyId = searchParams.get("babyId"); const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); 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 // Fetch last 100 medication events — enough to find latest per med
const events = await prisma.event.findMany({ const events = await prisma.event.findMany({
where: { babyId, type: "MEDICATION" }, where: { babyId, type: "MEDICATION" },
+4
View File
@@ -46,6 +46,10 @@ export async function POST(req: Request) {
const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } }); const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } });
if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); 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 loc = location ?? "fridge";
const storedDate = storedAt ? new Date(storedAt) : new Date(); const storedDate = storedAt ? new Date(storedAt) : new Date();
const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge; const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge;
+12 -1
View File
@@ -3,6 +3,17 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { generateWebhookSecret } from "@/lib/webhooks"; 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() { export async function GET() {
const session = await auth(); const session = await auth();
const familyId = (session?.user as { familyId?: string } | undefined)?.familyId; 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(); const { url, events } = await req.json();
if (!url || !events?.length) return NextResponse.json({ error: "url et events requis" }, { status: 400 }); 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(); const secret = generateWebhookSecret();
+15
View File
@@ -1,6 +1,20 @@
import { createHmac, randomBytes } from "crypto"; import { createHmac, randomBytes } from "crypto";
import { prisma } from "./prisma"; 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 = export type WebhookEventType =
| "event.created" | "event.created"
| "growth.created" | "growth.created"
@@ -26,6 +40,7 @@ export async function dispatchWebhook(
await Promise.allSettled( await Promise.allSettled(
hooks.map(async (hook) => { hooks.map(async (hook) => {
if (!isSafeWebhookUrl(hook.url)) return;
const sig = createHmac("sha256", hook.secret).update(body).digest("hex"); const sig = createHmac("sha256", hook.secret).update(body).digest("hex");
await fetch(hook.url, { await fetch(hook.url, {
method: "POST", method: "POST",