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>
7.2 KiB
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_PATHSincludes/api/v1/bootstrap/but NOT/setup. Comment at lines 60-62 says login + setup pages call bootstrap status, but/setupitself is not exempt from the session guard. Unauthenticated user →/setup→ middleware redirects to/login?redirect=/setup. Login useEffect fetches bootstrap status, callsrouter.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'toPUBLIC_PATHS.POST /api/v1/bootstrap/super-adminalready self-protects withhasAnySuperAdmin().
🟠 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 readsuseSearchParams(); alwaysrouter.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 viauseSearchParams(). 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+resendCrmInviteto emit${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}. Updateset-password/page.tsxto use the fragment-reading pattern fromPasswordSetForm(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 emitsX-RateLimit-Limit/Remaining/Reset(src/lib/rate-limit.ts:79-85).enforcePublicRateLimitaddsRetry-After; this route usescheckRateLimitdirectly and skips it. - Why it matters: RFC 6585 §4 requires
Retry-Afteron 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/'inPUBLIC_PATHS— every/portal/*is exempt from middleware session check. Per-pagegetPortalSession()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.tsfiles undersrc/app/api/ - What: No
OPTIONSexports. NoAccess-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/berthscross-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 innext.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_enabledgate —src/app/(portal)/layout.tsx:22callsisPortalDisabledGlobally(); per-pagegetPortalSession()additionally guards - R-022 reset-password tokens — Portal: single-use
consumeTokensettingusedAt, 30min TTL, SHA-256 hashed in DB. Better-auth CRM: 1h TTL,revokeSessionsOnPasswordReset: true - R-023 portal half —
portal/activate/page.tsxusesPasswordSetFormwithuseSyncExternalStore + readTokenFromUrl()readingwindow.location.hashclient-side; SSR-safe vianullserver snapshot - R-025 public berths cache headers
s-maxage=300, stale-while-revalidate=60confirmed in both list + single endpoints - R-026/027 public health: anonymous
{status,timestamp}only never 503;X-Intake-SecrettimingSafeEqual(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 againstpasswordChangedAtwatermark per request - S-19 audit log tamper-resistance —
audit_logshas noupdated_at; noUPDATEcalls in app code (only INSERT/SELECT and time-based retention DELETE bounded byAUDIT_LOGS_RETENTION_DAYS)