Files
Grow/SECURITY_AUDIT.md
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

12 KiB
Raw Permalink Blame History

Security Audit — Grow App

Date: 2026-06-15
Auditor: Claude Sonnet 4.6 (automated)
Scope: All API routes in src/app/api/


Summary

Severity Found Fixed
CRITICAL 9 9
HIGH 13 12
MEDIUM 10 8
LOW 1 0

Updated after third-pass audit (pass 3 of 3).


CRITICAL — Fixed

1. Missing family ownership check on baby data routes

Affected: events, growth, doctor-notes, journal, milestones, vaccinations, milk, search, export, reminders, teeth — all GET and POST handlers accepting babyId

Problem: Routes accepted any babyId from the caller and queried/wrote data without verifying the baby belongs to the authenticated user's family. An attacker with a valid session could enumerate any other family's data by guessing baby IDs (cuid values are not secret).

Fix: Added prisma.baby.findFirst({ where: { id: babyId, familyId } }) check before every Prisma query. Returns 404 if baby not owned by session family.


2. Missing ownership check on [id] mutation routes

Affected: events/[id], growth/[id], milestones/[id], vaccinations/[id], milk/[id], reminders/[id] — all PATCH and DELETE handlers

Problem: Routes updated or deleted records by bare ID with no ownership verification. Any authenticated user could destroy another family's data by providing the record ID.

Fix: Added findFirst({ where: { id, baby: { familyId } } }) before each PATCH/DELETE. Returns 404 if not owned.


3. Missing family check on journal [id] routes

Affected: journal/[id] PATCH and DELETE

Problem: Existing check verified note.userId === session.user.id (author check) but a PARENT from another family could modify any journal note they know the ID of (PARENT role bypasses the author check).

Fix: Added baby family check before the role/ownership check.


4. Missing family check on doctor-notes [id] routes

Affected: doctor-notes/[id] PATCH and DELETE

Problem: Same pattern — the note's baby family was never verified. A doctor from family A could modify notes for family B if they know the note ID.

Fix: Added baby family check before the userId ownership check.


5. baby/[id] PATCH — any authenticated user could modify any baby

Affected: baby/[id]/route.ts PATCH

Problem: No family or role check. Any authenticated user with a baby ID could rename or change birth dates for babies in other families.

Fix: Added familyId check + PARENT role requirement.


6. Admin config GET exposes sensitive secrets

Affected: admin/config/route.ts GET

Problem: The GET endpoint returned plaintext values for mailgun_api_key, smtp_pass, cron_secret, and pushover_app_token to any superadmin session. These secrets are write-only configuration.

Fix: Sensitive keys masked as •••••• in GET response. POST (update) still accepts and stores new values normally.


HIGH — Fixed

7. Photo files served without authentication

Affected: photos/[filename]/route.ts GET

Problem: Photo files were publicly accessible to anyone who knew (or guessed) the filename. Filenames include timestamps and short random suffixes — not truly unguessable.

Fix: Added auth() check; returns 404 (not 401) to avoid confirming file existence.


8. Missing PARENT role check on invite send-email

Affected: invite/send-email/route.ts POST

Problem: Any authenticated user (including READONLY viewers) could send family invitations, bypassing the intended restriction to PARENT role.

Fix: Added if (user.role !== "PARENT") return 403 after fetching the user record.


9. teeth/[code] DELETE — no family ownership check

Affected: teeth/[code]/route.ts DELETE

Problem: Took babyId from query param but didn't verify baby belongs to user's family.

Fix: Added baby family ownership check.


HIGH — Not fixed (by design / low exploitability)

10. Token enumeration via invite token endpoints

Affected: invite/token-info, invite/redeem

Observation: Different HTTP status codes for invalid (404) vs expired (410) tokens allow an attacker to determine whether a token ever existed. However: tokens are cuid() values (128 bits of entropy), making enumeration computationally infeasible. No action taken.


11. Admin config stores cron_secret plaintext in DB

Observation: Cron endpoints use a Bearer token stored in SystemConfig table (plaintext). Acceptable risk given superadmin-only DB access. Consider hashing in a future iteration.


MEDIUM — Fixed

12. reminders/[id] PATCH and DELETE — no ownership check

Fixed in batch with other [id] routes above.


MEDIUM — Not fixed (accepted risk / architectural)

13. Webhook secret returned in GET/LIST response

Observation: The POST (create) response returns the secret once — consistent with API key best practice. The GET list already excludes secret via Prisma select. Confirmed no leak.

14. v1 API events — type param not validated against allowlist

Observation: Prisma parameterizes the type value, so no SQL injection risk. However, clients could store arbitrary type strings. Low priority.

15. familyId in JWT not re-validated on each request

Observation: familyId is read from the JWT session without a DB round-trip. If a user is removed from a family in the DB, their token remains valid until expiration. Next-auth's default session expiry (30 days) makes this window wide. Mitigation: reduce maxAge in next-auth config, or add a session validation hook.


LOW

16. Superadmin check falls through if SUPERADMIN_EMAIL is unset

Affected: admin/config, admin/audit, admin/stats, admin/users, etc.

If SUPERADMIN_EMAIL env var is not set, the condition SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL short-circuits to false, making the second condition (!user?.isSuperAdmin) the only gate. This is technically correct but relies on isSuperAdmin being set properly in the DB. Acceptable given the admin routes are superadmin-only by convention.



HIGH — Fixed (pass 2)

17. medication-profiles/[id] — no family ownership check

Affected: PATCH and DELETE

Problem: Any authenticated user could modify or delete any medication profile by ID.

Fix: Added prisma.medicationProfile.findFirst({ where: { id, familyId } }) check before PATCH and DELETE.


18. event-templates/[id] — no family ownership check

Affected: PATCH and DELETE

Problem: Any authenticated user could modify or delete any event template by ID.

Fix: Added prisma.eventTemplate.findFirst({ where: { id, familyId } }) check before PATCH and DELETE.


19. notify/push — no family ownership check on babyId

Problem: Baby was fetched by ID but its familyId was never compared against the session user's family. Any authenticated user could trigger push notifications using another family's baby ID (leaking feeding status via the notification message).

Fix: Added if (baby.familyId !== sessionFamilyId) return 404.


20. events/route.ts GET — event type not validated against allowlist

Problem: The type query param was passed directly into the Prisma where clause without validation. While Prisma parameterizes values (no SQL injection), arbitrary strings could be stored and returned.

Fix: Imported ALL_EVENT_TYPES from event-config.ts and added if (type && !ALL_EVENT_TYPES.includes(type)) return 400.


21. events/route.ts GET — limit/offset not sanitized

Problem: parseInt() returns NaN on non-numeric input; NaN passed to Prisma take/skip causes an error or is treated as 0, potentially returning all rows.

Fix: Added clamping: limit = Math.max(1, Math.min(parseInt(limit) || 50, 500)), offset = Math.max(0, parseInt(offset) || 0).


MEDIUM — Fixed (pass 2)

22. v1/summary — arithmetic operator precedence bug

Problem: counts.BREASTFEED ?? 0 + (counts.BOTTLE ?? 0) evaluated as counts.BREASTFEED ?? (0 + (counts.BOTTLE ?? 0)) due to ?? precedence, causing incorrect feed count.

Fix: Added explicit parentheses: (counts.BREASTFEED ?? 0) + (counts.BOTTLE ?? 0).


23. v1/events GET — offset not sanitized (pass 2)

Fix: Math.max(0, parseInt(offset) || 0). Also fixed limit: Math.min of NaN returns NaN; added || 50 fallback.


24. v1/growth POST — date not validated before new Date()

Fix: Added if (isNaN(new Date(date).getTime())) return 400.



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)

Upload route — extension from MIME type, not filename

The audit flagged upload/route.ts for unsafe extension extraction. The route derives the extension from file.type (e.g., "image/jpeg".split("/")[1]) — not from the original filename — and ALLOWED_TYPES.includes(file.type) is checked before that line. No fix needed.


Routes verified as already secure

  • webhooks/route.ts — familyId from session, no cross-family access possible
  • webhooks/[id]/route.tsfindFirst({ where: { id, familyId } }) already present
  • invite/pending/[id]/route.ts — familyId from DB lookup before delete
  • family/reset-invite/route.ts — PARENT role check present
  • medication-profiles/route.ts — familyId from DB lookup, fully scoped
  • api-keys/route.ts — familyId scoped
  • All admin/* routes — superadmin check before any action
  • All cron/* routes — CRON_SECRET bearer token required

Recommendations (not implemented)

  1. Rate limiting on auth endpoints (/api/auth/*, /api/register, /api/invite/*) — use @upstash/ratelimit or a middleware layer.
  2. Session TTL reduction — default 30d is long; consider 7d with sliding expiry.
  3. Audit log on all DELETE operations — currently only events have logAudit.
  4. CORS policy — verify NEXTAUTH_URL is set to prevent open redirects on OAuth callbacks.