Files
pn-new-crm/docs/audit-findings-tmp/03-routes-auth.md
Matt 4b5f85cb7d fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00

7.2 KiB

Routes/Middleware/Auth Audit (R-016-029, S-09-13, S-17-19) — agent #3

Headline: 1 critical (/setup unreachable on fresh DB — middleware redirect loop), 3 high (post-login ?redirect= ignored; CRM invite token in query string leaks to access logs; missing Retry-After on sign-in 429), 2 medium (broad portal allowlist, no OPTIONS handlers), 13 clean.

Counts: 1 critical · 3 high · 2 medium · 0 low · 13 passing


🔴 CRITICAL R-021: /setup missing from PUBLIC_PATHS — bootstrap unreachable on fresh DB

  • File: src/proxy.ts:51-73
  • What: PUBLIC_PATHS includes /api/v1/bootstrap/ but NOT /setup. Comment at lines 60-62 says login + setup pages call bootstrap status, but /setup itself is not exempt from the session guard. Unauthenticated user → /setup → middleware redirects to /login?redirect=/setup. Login useEffect fetches bootstrap status, calls router.replace('/setup') → middleware again → infinite redirect loop.
  • Why it matters: Fresh deployment (no super admin) is functionally deadlocked. First operator cannot reach setup without already having a session (impossible on fresh DB).
  • Suggested fix: Add '/setup' to PUBLIC_PATHS. POST /api/v1/bootstrap/super-admin already self-protects with hasAnySuperAdmin().

🟠 HIGH R-017/018: CRM post-login redirect ignores ?redirect= — deep links silently dropped

  • File: src/app/(auth)/login/page.tsx:79
  • What: Middleware redirects unauthenticated → /login?redirect=<path>. Login page never reads useSearchParams(); always router.push('/dashboard').
  • Why it matters: Email/bookmark/shared deep links into specific clients/interests silently dump to dashboard after login.
  • Suggested fix: Read searchParams.get('redirect'), validate same-origin (starts with /, not //), use as push target if valid.

🟠 HIGH R-023: CRM invite token in query string leaks to access logs

  • File: src/lib/services/crm-invite.service.ts:71,233
  • What: ${env.APP_URL}/set-password?token=${raw} — raw 32-byte token in query param. Set-password page reads via useSearchParams(). Portal flow was migrated to #token= fragment in 2026-05-14 specifically to keep tokens out of logs/Referer; CRM invite path missed the migration.
  • Why it matters: Every nginx/Caddy access log line for GET /set-password?token=<raw> persists token to disk. Forwarded to SIEM/S3/monitoring → token visible to anyone with log access. Token grants account creation.
  • Suggested fix: Change createCrmInvite + resendCrmInvite to emit ${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}. Update set-password/page.tsx to use the fragment-reading pattern from PasswordSetForm (readTokenFromUrl()) with ?token= back-compat for outstanding tokens.

🟠 HIGH R-029: sign-in-by-identifier 429 missing Retry-After

  • File: src/app/api/auth/sign-in-by-identifier/route.ts:47-51
  • What: Builds 429 response with headers: rateLimitHeaders(rl) which only emits X-RateLimit-Limit/Remaining/Reset (src/lib/rate-limit.ts:79-85). enforcePublicRateLimit adds Retry-After; this route uses checkRateLimit directly and skips it.
  • Why it matters: RFC 6585 §4 requires Retry-After on 429. Automated clients can't back off correctly. Inconsistent with other public endpoints.
  • Suggested fix: Add 'Retry-After': Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000)).toString().

🟡 MEDIUM R-016: /portal/ blanket allowlist removes middleware as backstop

  • File: src/proxy.ts:65
  • What: '/portal/' in PUBLIC_PATHS — every /portal/* is exempt from middleware session check. Per-page getPortalSession() is the only gate.
  • Why it matters: Defense-in-depth gap. Per-page checks all in place today; but a future portal page added without getPortalSession() has no middleware backstop. Fragile vs CRM's primary middleware gate.
  • Suggested fix: Allowlist only the unauthenticated portal routes individually (/portal/login, /portal/activate, /portal/reset-password, /portal/forgot-password). Add middleware portal-cookie check.

🟡 MEDIUM R-028: No explicit OPTIONS handlers, no CORS headers

  • File: All route.ts files under src/app/api/
  • What: No OPTIONS exports. No Access-Control-Allow-* headers anywhere. Next.js will 405 on unhandled OPTIONS.
  • Why it matters: Acceptable for same-origin CRM. Becomes an issue if marketing-site browser JS calls /api/public/berths cross-origin.
  • Suggested fix: Defer until cross-origin consumer exists. When marketing site lives, add explicit Access-Control-Allow-Origin: <marketing-domain> to public routes (not wildcard).

Passing checks

  • R-016 allow-list anchor — startsWith('/api/public/') correctly rejects '/api/publicX-evil' (no regex anchor concern)
  • S-09 open redirect on next/redirect — CRM login ignores param (no risk because unused); portal safeNextPath() (portal/login/page.tsx:20-27) rejects non-/portal/ paths and //-protocol-relative
  • S-10 CSRF — defense-in-depth: proxy.ts originAllowed() (lines 104-122) rejects state-changing /api/v1/** where Origin/Referer don't match in prod; better-auth has its own origin check for /api/auth/**; dev bypass intentional
  • S-11 cookie flags — CRM: httpOnly, secure (prod), sameSite: 'strict' (src/lib/auth/index.ts:107-110); Portal: httpOnly, secure (prod), sameSite: 'lax' (src/app/api/portal/auth/sign-in/route.ts:43-45)
  • S-12 CSP — per-request nonce-based CSP via proxy.ts:buildCspWithNonce() for page routes in prod ('nonce-<n>' 'strict-dynamic'); fallback CSP in next.config.ts:55-66; frame-ancestors: 'none' + X-Frame-Options: DENY; HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy all present
  • S-13 CORS — no Access-Control-Allow-Origin: * anywhere (correct for same-origin CRM)
  • R-019/020 portal client_portal_enabled gate — src/app/(portal)/layout.tsx:22 calls isPortalDisabledGlobally(); per-page getPortalSession() additionally guards
  • R-022 reset-password tokens — Portal: single-use consumeToken setting usedAt, 30min TTL, SHA-256 hashed in DB. Better-auth CRM: 1h TTL, revokeSessionsOnPasswordReset: true
  • R-023 portal half — portal/activate/page.tsx uses PasswordSetForm with useSyncExternalStore + readTokenFromUrl() reading window.location.hash client-side; SSR-safe via null server snapshot
  • R-025 public berths cache headers s-maxage=300, stale-while-revalidate=60 confirmed in both list + single endpoints
  • R-026/027 public health: anonymous {status,timestamp} only never 503; X-Intake-Secret timingSafeEqual (lines 57-64); authenticated runs DB+Redis dep checks in parallel, 503 on either failure
  • S-17 session fixation — better-auth creates fresh session row on every sign-in; portal sign-in always issues new JWT via createPortalToken
  • S-18 token expiry/refresh — CRM 24h absolute, 6h sliding refresh window (src/lib/auth/index.ts:99-103); Portal JWT 24h checked against passwordChangedAt watermark per request
  • S-19 audit log tamper-resistance — audit_logs has no updated_at; no UPDATE calls in app code (only INSERT/SELECT and time-based retention DELETE bounded by AUDIT_LOGS_RETENTION_DAYS)