All collection routes (events, growth, doctor-notes, journal, milestones, vaccinations, milk, reminders, teeth, search, export) now verify the requested babyId belongs to the authenticated user's family before querying or writing. All [id] mutation routes verify record ownership via nested baby→familyId before any PATCH/DELETE. Additional fixes: admin config masks sensitive secrets in GET response, invite send-email enforces PARENT role, photo serving requires authentication, baby PATCH restricted to own-family PARENT. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7.2 KiB
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 | 6 | 5 |
| MEDIUM | 6 | 2 |
| LOW | 1 | 0 |
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.
Routes verified as already secure
webhooks/route.ts— familyId from session, no cross-family access possiblewebhooks/[id]/route.ts—findFirst({ where: { id, familyId } })already presentinvite/pending/[id]/route.ts— familyId from DB lookup before deletefamily/reset-invite/route.ts— PARENT role check presentmedication-profiles/route.ts— familyId from DB lookup, fully scopedapi-keys/route.ts— familyId scoped- All
admin/*routes — superadmin check before any action - All
cron/*routes — CRON_SECRET bearer token required
Recommendations (not implemented)
- Rate limiting on auth endpoints (
/api/auth/*,/api/register,/api/invite/*) — use@upstash/ratelimitor a middleware layer. - Session TTL reduction — default 30d is long; consider 7d with sliding expiry.
- Audit log on all DELETE operations — currently only events have
logAudit. - CORS policy — verify
NEXTAUTH_URLis set to prevent open redirects on OAuth callbacks.