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:
+47
-3
@@ -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.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)
|
## MEDIUM — Not applicable (already handled)
|
||||||
|
|
||||||
### Upload route — extension from MIME type, not filename
|
### Upload route — extension from MIME type, not filename
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user