Files
pn-new-crm/src/proxy.ts

272 lines
12 KiB
TypeScript
Raw Normal View History

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
fix(audit-wave-11): CSP nonce middleware — drops 'unsafe-inline' in prod build-auditor H1: prod `script-src` previously kept `'unsafe-inline'` because dropping it requires a per-request nonce that Next's RSC bootstrap + Server Actions can thread into their inline scripts. Implement the nonce mechanism in `src/proxy.ts`: 1. Mint a base64-encoded UUID per request as the CSP nonce. 2. Set the nonce on the REQUEST headers via `content-security-policy` + `x-nonce` so Next.js's RSC layer reads the active CSP and stamps `nonce=<value>` onto every inline `<script>` it emits (Next's documented pattern). 3. Set the matching `Content-Security-Policy` on the RESPONSE so the browser actually enforces it. Prod CSP becomes: `script-src 'self' 'nonce-<value>' 'strict-dynamic'` `'strict-dynamic'` lets nonce-tagged scripts load further scripts they trust, which is how Next chunks the rest of the bundle in. Inline `<script>` without a nonce is now rejected by the browser — closes the canonical XSS pathway. Dev keeps `'unsafe-inline' 'unsafe-eval'` because Next's HMR evaluates code at runtime and the nonce machinery doesn't reach it. `style-src` keeps `'unsafe-inline'` because Tailwind + Radix runtime style injection has no nonce story yet. Revisit when Tailwind v5 ships a nonce-able API. The static CSP in `next.config.ts` stays as a fallback for static assets / API JSON paths that don't run through the proxy. Updated the comment so future readers know the proxy CSP takes precedence for HTML responses. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:04:30 +02:00
/**
* Per-request CSP nonce - drops `'unsafe-inline'` from script-src in
fix(audit-wave-11): CSP nonce middleware — drops 'unsafe-inline' in prod build-auditor H1: prod `script-src` previously kept `'unsafe-inline'` because dropping it requires a per-request nonce that Next's RSC bootstrap + Server Actions can thread into their inline scripts. Implement the nonce mechanism in `src/proxy.ts`: 1. Mint a base64-encoded UUID per request as the CSP nonce. 2. Set the nonce on the REQUEST headers via `content-security-policy` + `x-nonce` so Next.js's RSC layer reads the active CSP and stamps `nonce=<value>` onto every inline `<script>` it emits (Next's documented pattern). 3. Set the matching `Content-Security-Policy` on the RESPONSE so the browser actually enforces it. Prod CSP becomes: `script-src 'self' 'nonce-<value>' 'strict-dynamic'` `'strict-dynamic'` lets nonce-tagged scripts load further scripts they trust, which is how Next chunks the rest of the bundle in. Inline `<script>` without a nonce is now rejected by the browser — closes the canonical XSS pathway. Dev keeps `'unsafe-inline' 'unsafe-eval'` because Next's HMR evaluates code at runtime and the nonce machinery doesn't reach it. `style-src` keeps `'unsafe-inline'` because Tailwind + Radix runtime style injection has no nonce story yet. Revisit when Tailwind v5 ships a nonce-able API. The static CSP in `next.config.ts` stays as a fallback for static assets / API JSON paths that don't run through the proxy. Updated the comment so future readers know the proxy CSP takes precedence for HTML responses. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:04:30 +02:00
* prod by giving every inline script a unique nonce that Next reads
* from the `content-security-policy` REQUEST header and threads through
* its RSC bootstrap + Server Actions. build-auditor H1.
*
* Dev keeps `'unsafe-inline' 'unsafe-eval'` because Next HMR injects
* runtime-evaluated scripts the nonce mechanism doesn't reach.
* style-src stays at `'unsafe-inline'` because Tailwind/Radix runtime
* style injection has no nonce story yet (revisit when Tailwind v5
* ships a nonce-able API).
*/
function buildCspWithNonce(nonce: string, isProd: boolean): string {
const scriptSrc = isProd
? `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`
: "script-src 'self' 'unsafe-inline' 'unsafe-eval' http://unpkg.com https://unpkg.com";
const connectSrc = isProd
? "connect-src 'self' ws: wss: https:"
: "connect-src 'self' ws: wss: https: http://unpkg.com https://unpkg.com";
return [
"default-src 'self'",
scriptSrc,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
connectSrc,
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join('; ');
}
function generateNonce(): string {
// crypto.randomUUID is collision-free across requests; base64-encode
// to match the CSP spec shape (no `-`/`_`-only restrictions for
// CSP3 nonce values, but base64 stays safe across HTTP header
// serialization).
return Buffer.from(crypto.randomUUID()).toString('base64');
}
/**
* Paths that do not require an authenticated session.
* Checked with startsWith, so /auth/ covers /auth/callback etc.
*/
const PUBLIC_PATHS: string[] = [
'/login',
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
'/reset-password',
'/set-password',
'/auth/',
'/api/auth/',
'/api/public/',
'/api/health',
'/api/webhooks/',
// First-run / cold-start: the unauthenticated /setup and /login pages
// call /api/v1/bootstrap/status to decide whether to render the setup
// form. The route handlers self-protect via hasAnySuperAdmin().
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
'/setup',
'/api/v1/bootstrap/',
'/scan',
// Tracked-link redirector. Outbound sales email embeds public
// `<APP_URL>/q/<slug>` links whose only audience is unauthenticated
// external recipients. The route self-protects (validates the slug
// regex before any DB hit and only 302s to an admin-stored target),
// so it belongs on the anonymous allowlist. Without this, every
// tracked link bounced recipients to /login (audit C4).
'/q/',
// §7.1: public sales-playbook docs (deal pulse, etc) so the "Full
// guide" link inside the in-app popover is reachable without a
// session - and shareable to external collaborators.
'/docs/',
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
// M-R01: portal allowlist narrowed from blanket `/portal/` to the
// unauthenticated entry-point routes only. Other `/portal/*` paths
// now flow through the middleware backstop below which redirects to
// `/portal/login` when the portal_session cookie is missing. Closes
// the silent-bypass class where a new portal route landed without
// its own session check.
'/portal/login',
'/portal/activate',
'/portal/reset-password',
// Portal API endpoints handle their own session checks (better-auth).
'/api/portal/',
// Token-gated email-change endpoints. The confirm/cancel links land in
// a fresh browser (the user may not be signed in on this device), so
// they need to bypass the session 401 gate. The endpoints validate a
// signed sha256-hashed token instead - that's the auth.
'/api/v1/me/email/confirm/',
'/api/v1/me/email/cancel/',
];
function isPublicPath(pathname: string): boolean {
// Per-port PWA manifests sit under `/<portSlug>/scan/manifest.webmanifest`
// and need to be fetchable without a session - browsers fetch them eagerly
// during install / first paint. The manifest only contains port name +
// icon paths, no sensitive data, so making it public is safe.
if (pathname.endsWith('/scan/manifest.webmanifest')) return true;
return PUBLIC_PATHS.some((prefix) => pathname === prefix || pathname.startsWith(prefix));
}
function isApiRoute(pathname: string): boolean {
return pathname.startsWith('/api/');
}
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
/**
* SameSite=Lax cookies block top-level cross-site POSTs in modern browsers,
* but defense-in-depth: every state-changing request to a session-authed
* `/api/v1/**` endpoint must originate from the same origin as the app.
* Webhooks (`/api/webhooks/**`) and public posts (`/api/public/**`) are
* exempt because they're called by external systems with no session
* cookie. Auth flows (`/api/auth/**`) and portal (`/api/portal/**`) handle
* their own origin/CSRF checks via better-auth.
*/
function isOriginCheckedPath(pathname: string): boolean {
if (!pathname.startsWith('/api/v1/')) return false;
return true;
}
function originAllowed(request: NextRequest): boolean {
const origin = request.headers.get('origin');
const referer = request.headers.get('referer');
// Compare HOSTS, not full origins. TLS terminates at the reverse proxy,
// so the upstream request the app sees is http://127.0.0.1 — its
// protocol is unreliable, and request.nextUrl.origin reads `http` while
// the browser's Origin is `https`, which would reject every same-site
// mutation in production. The Host header is preserved across the proxy,
// and a matching host is what same-origin CSRF defense actually needs
// (a cross-site attacker can't forge the browser-set Origin host).
const hostOf = (value: string | null): string | null => {
if (!value) return null;
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
try {
return new URL(value).host;
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
} catch {
return null;
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
}
};
// Acceptable hosts. Behind the TLS-terminating proxy request.nextUrl.host
// can be the upstream bind (127.0.0.1:PORT) rather than the public host,
// so it can't be the sole source of truth. The Host header is forwarded
// verbatim by nginx (`proxy_set_header Host $host`), and APP_URL is the
// canonical configured origin — trust those too. Comparing hosts (not
// full origins) is intentional: TLS terminates upstream so the protocol
// is unreliable, and a matching host is what CSRF defense needs.
const allowedHosts = new Set(
[request.headers.get('host'), hostOf(process.env.APP_URL ?? null), request.nextUrl.host].filter(
(h): h is string => Boolean(h),
),
);
const candidate = origin ? hostOf(origin) : referer ? hostOf(referer) : null;
if (candidate !== null) return allowedHosts.has(candidate);
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
// Neither header present: most browser fetches always send Origin on
// POST/PUT/PATCH/DELETE, so this likely means a same-origin server-side
// call (e.g. Next.js internal fetch). Allow.
return true;
}
fix(audit-wave-11): CSP nonce middleware — drops 'unsafe-inline' in prod build-auditor H1: prod `script-src` previously kept `'unsafe-inline'` because dropping it requires a per-request nonce that Next's RSC bootstrap + Server Actions can thread into their inline scripts. Implement the nonce mechanism in `src/proxy.ts`: 1. Mint a base64-encoded UUID per request as the CSP nonce. 2. Set the nonce on the REQUEST headers via `content-security-policy` + `x-nonce` so Next.js's RSC layer reads the active CSP and stamps `nonce=<value>` onto every inline `<script>` it emits (Next's documented pattern). 3. Set the matching `Content-Security-Policy` on the RESPONSE so the browser actually enforces it. Prod CSP becomes: `script-src 'self' 'nonce-<value>' 'strict-dynamic'` `'strict-dynamic'` lets nonce-tagged scripts load further scripts they trust, which is how Next chunks the rest of the bundle in. Inline `<script>` without a nonce is now rejected by the browser — closes the canonical XSS pathway. Dev keeps `'unsafe-inline' 'unsafe-eval'` because Next's HMR evaluates code at runtime and the nonce machinery doesn't reach it. `style-src` keeps `'unsafe-inline'` because Tailwind + Radix runtime style injection has no nonce story yet. Revisit when Tailwind v5 ships a nonce-able API. The static CSP in `next.config.ts` stays as a fallback for static assets / API JSON paths that don't run through the proxy. Updated the comment so future readers know the proxy CSP takes precedence for HTML responses. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:04:30 +02:00
/**
* Apply per-request CSP to a NextResponse. Skipped for API routes
* (they don't render HTML so script-src is irrelevant) and for
* already-built responses that have set their own CSP (e.g. redirects
* where the static next.config CSP applies).
*/
function applyCsp(response: NextResponse, nonce: string, pathname: string): NextResponse {
if (isApiRoute(pathname)) return response;
const isProd = process.env.NODE_ENV === 'production';
response.headers.set('Content-Security-Policy', buildCspWithNonce(nonce, isProd));
response.headers.set('x-nonce', nonce);
return response;
}
2026-05-12 22:24:51 +02:00
export function proxy(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl;
fix(audit-wave-11): CSP nonce middleware — drops 'unsafe-inline' in prod build-auditor H1: prod `script-src` previously kept `'unsafe-inline'` because dropping it requires a per-request nonce that Next's RSC bootstrap + Server Actions can thread into their inline scripts. Implement the nonce mechanism in `src/proxy.ts`: 1. Mint a base64-encoded UUID per request as the CSP nonce. 2. Set the nonce on the REQUEST headers via `content-security-policy` + `x-nonce` so Next.js's RSC layer reads the active CSP and stamps `nonce=<value>` onto every inline `<script>` it emits (Next's documented pattern). 3. Set the matching `Content-Security-Policy` on the RESPONSE so the browser actually enforces it. Prod CSP becomes: `script-src 'self' 'nonce-<value>' 'strict-dynamic'` `'strict-dynamic'` lets nonce-tagged scripts load further scripts they trust, which is how Next chunks the rest of the bundle in. Inline `<script>` without a nonce is now rejected by the browser — closes the canonical XSS pathway. Dev keeps `'unsafe-inline' 'unsafe-eval'` because Next's HMR evaluates code at runtime and the nonce machinery doesn't reach it. `style-src` keeps `'unsafe-inline'` because Tailwind + Radix runtime style injection has no nonce story yet. Revisit when Tailwind v5 ships a nonce-able API. The static CSP in `next.config.ts` stays as a fallback for static assets / API JSON paths that don't run through the proxy. Updated the comment so future readers know the proxy CSP takes precedence for HTML responses. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:04:30 +02:00
// Mint a per-request nonce up-front so HTML responses can carry it.
// Cheap (one UUID + base64) so we always do it; the apply step
// skips API routes that don't need it.
const nonce = generateNonce();
const isProd = process.env.NODE_ENV === 'production';
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
// CSRF defense-in-depth: state-changing requests to authed /api/v1
// endpoints must come from the app's own origin. Skipped in dev so
// LAN testing (e.g. real iPhone hitting the Mac via 192.168.x.x while
// a Mac browser tab is loaded from localhost) doesn't trip on the
// origin mismatch. Production keeps the check.
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
if (
process.env.NODE_ENV !== 'development' &&
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
STATE_CHANGING_METHODS.has(request.method) &&
isOriginCheckedPath(pathname) &&
!originAllowed(request)
) {
return NextResponse.json(
{ error: 'Cross-origin state-changing request rejected' },
{ status: 403 },
);
}
// Always allow public paths through
if (isPublicPath(pathname)) {
fix(audit-wave-11): CSP nonce middleware — drops 'unsafe-inline' in prod build-auditor H1: prod `script-src` previously kept `'unsafe-inline'` because dropping it requires a per-request nonce that Next's RSC bootstrap + Server Actions can thread into their inline scripts. Implement the nonce mechanism in `src/proxy.ts`: 1. Mint a base64-encoded UUID per request as the CSP nonce. 2. Set the nonce on the REQUEST headers via `content-security-policy` + `x-nonce` so Next.js's RSC layer reads the active CSP and stamps `nonce=<value>` onto every inline `<script>` it emits (Next's documented pattern). 3. Set the matching `Content-Security-Policy` on the RESPONSE so the browser actually enforces it. Prod CSP becomes: `script-src 'self' 'nonce-<value>' 'strict-dynamic'` `'strict-dynamic'` lets nonce-tagged scripts load further scripts they trust, which is how Next chunks the rest of the bundle in. Inline `<script>` without a nonce is now rejected by the browser — closes the canonical XSS pathway. Dev keeps `'unsafe-inline' 'unsafe-eval'` because Next's HMR evaluates code at runtime and the nonce machinery doesn't reach it. `style-src` keeps `'unsafe-inline'` because Tailwind + Radix runtime style injection has no nonce story yet. Revisit when Tailwind v5 ships a nonce-able API. The static CSP in `next.config.ts` stays as a fallback for static assets / API JSON paths that don't run through the proxy. Updated the comment so future readers know the proxy CSP takes precedence for HTML responses. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:04:30 +02:00
// Forward the nonce in the REQUEST header so Next's RSC bootstrap
// can read it via `headers()` and stamp it onto every inline
// <script>. The browser-facing CSP is set on the response below.
const requestHeaders = new Headers(request.headers);
requestHeaders.set('content-security-policy', buildCspWithNonce(nonce, isProd));
requestHeaders.set('x-nonce', nonce);
return applyCsp(NextResponse.next({ request: { headers: requestHeaders } }), nonce, pathname);
}
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
// M-R01: portal pages use a distinct cookie (`portal_session`) from
// the CRM (`pn-crm.session_token`). Backstop here so any future
// /portal/* page that forgets its own session check gets caught.
if (pathname.startsWith('/portal/')) {
const portalSession = request.cookies.get('portal_session');
if (!portalSession?.value) {
const loginUrl = new URL('/portal/login', request.url);
loginUrl.searchParams.set('redirect', pathname + request.nextUrl.search);
return NextResponse.redirect(loginUrl);
}
const requestHeaders = new Headers(request.headers);
requestHeaders.set('content-security-policy', buildCspWithNonce(nonce, isProd));
requestHeaders.set('x-nonce', nonce);
return applyCsp(NextResponse.next({ request: { headers: requestHeaders } }), nonce, pathname);
}
// better-auth prefixes the cookie with `__Secure-` whenever it issues
// secure cookies (production / HTTPS), so the name on the wire is
// `__Secure-pn-crm.session_token` in prod but bare `pn-crm.session_token`
// in dev. Check both, or every authenticated request in prod gets
// bounced to /login because the gate can't find the (prefixed) cookie.
const sessionToken =
request.cookies.get('pn-crm.session_token') ??
request.cookies.get('__Secure-pn-crm.session_token');
if (!sessionToken?.value) {
if (isApiRoute(pathname)) {
// API routes return 401 JSON - never redirect
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
// Page routes redirect to /login, preserving the intended destination
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname + request.nextUrl.search);
return NextResponse.redirect(loginUrl);
}
fix(audit-wave-11): CSP nonce middleware — drops 'unsafe-inline' in prod build-auditor H1: prod `script-src` previously kept `'unsafe-inline'` because dropping it requires a per-request nonce that Next's RSC bootstrap + Server Actions can thread into their inline scripts. Implement the nonce mechanism in `src/proxy.ts`: 1. Mint a base64-encoded UUID per request as the CSP nonce. 2. Set the nonce on the REQUEST headers via `content-security-policy` + `x-nonce` so Next.js's RSC layer reads the active CSP and stamps `nonce=<value>` onto every inline `<script>` it emits (Next's documented pattern). 3. Set the matching `Content-Security-Policy` on the RESPONSE so the browser actually enforces it. Prod CSP becomes: `script-src 'self' 'nonce-<value>' 'strict-dynamic'` `'strict-dynamic'` lets nonce-tagged scripts load further scripts they trust, which is how Next chunks the rest of the bundle in. Inline `<script>` without a nonce is now rejected by the browser — closes the canonical XSS pathway. Dev keeps `'unsafe-inline' 'unsafe-eval'` because Next's HMR evaluates code at runtime and the nonce machinery doesn't reach it. `style-src` keeps `'unsafe-inline'` because Tailwind + Radix runtime style injection has no nonce story yet. Revisit when Tailwind v5 ships a nonce-able API. The static CSP in `next.config.ts` stays as a fallback for static assets / API JSON paths that don't run through the proxy. Updated the comment so future readers know the proxy CSP takes precedence for HTML responses. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:04:30 +02:00
const requestHeaders = new Headers(request.headers);
requestHeaders.set('content-security-policy', buildCspWithNonce(nonce, isProd));
requestHeaders.set('x-nonce', nonce);
return applyCsp(NextResponse.next({ request: { headers: requestHeaders } }), nonce, pathname);
}
export const config = {
matcher: [
/*
* Match all request paths except:
feat(client-archive): single-client smart-archive dialog + CSP/middleware fixups UI side of the smart-archive backend that shipped in d07f1ed. - SmartArchiveDialog renders the dossier as a sectioned modal: Pipeline interests, Berths (with next-in-line listed), Yachts, Active reservations, Outstanding invoices, In-flight Documenso envelopes, Auto-handled summary. Each section has a per-row decision dropdown with sensible defaults (release for available/under-offer berths, retain for sold berths and yachts, cancel for active reservations, leave for invoices and documents). - High-stakes deals show an amber warning panel + require a reason in the textarea before the Archive button enables. Signed-document acknowledgment checkbox blocks submission until checked. - Wires into client-detail-header in place of the previous dumb ArchiveConfirmDialog (the simple confirm dialog is kept for the restore case until the smart-restore wizard ships). - Pre-flight blocker banner surfaces dossier.blockers (e.g. active reservation on a sold berth) and disables the Archive button entirely. Two side fixes from CSP rollout: - next.config CSP allows unpkg.com in dev so the react-grab devtool loads. Stripped in prod via the existing isProd flag. - middleware whitelist now passes /manifest.json + icon-*.png + apple-touch-icon through unauthenticated, so PWA installability isn't blocked by the auth redirect. Bulk variant + restore wizard + hard-delete-with-email-code land in follow-on commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:19:34 +02:00
* - _next/static (static files)
* - _next/image (Next.js image optimisation)
* - favicon.ico (browser tab icon)
* - /images/ (public image assets)
* - manifest.json (PWA manifest - must be unauthed for installability)
feat(client-archive): single-client smart-archive dialog + CSP/middleware fixups UI side of the smart-archive backend that shipped in d07f1ed. - SmartArchiveDialog renders the dossier as a sectioned modal: Pipeline interests, Berths (with next-in-line listed), Yachts, Active reservations, Outstanding invoices, In-flight Documenso envelopes, Auto-handled summary. Each section has a per-row decision dropdown with sensible defaults (release for available/under-offer berths, retain for sold berths and yachts, cancel for active reservations, leave for invoices and documents). - High-stakes deals show an amber warning panel + require a reason in the textarea before the Archive button enables. Signed-document acknowledgment checkbox blocks submission until checked. - Wires into client-detail-header in place of the previous dumb ArchiveConfirmDialog (the simple confirm dialog is kept for the restore case until the smart-restore wizard ships). - Pre-flight blocker banner surfaces dossier.blockers (e.g. active reservation on a sold berth) and disables the Archive button entirely. Two side fixes from CSP rollout: - next.config CSP allows unpkg.com in dev so the react-grab devtool loads. Stripped in prod via the existing isProd flag. - middleware whitelist now passes /manifest.json + icon-*.png + apple-touch-icon through unauthenticated, so PWA installability isn't blocked by the auth redirect. Bulk variant + restore wizard + hard-delete-with-email-code land in follow-on commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:19:34 +02:00
* - icon-*.png (PWA + apple-touch icons referenced by manifest)
* - apple-touch-icon (iOS home-screen icon)
*/
feat(client-archive): single-client smart-archive dialog + CSP/middleware fixups UI side of the smart-archive backend that shipped in d07f1ed. - SmartArchiveDialog renders the dossier as a sectioned modal: Pipeline interests, Berths (with next-in-line listed), Yachts, Active reservations, Outstanding invoices, In-flight Documenso envelopes, Auto-handled summary. Each section has a per-row decision dropdown with sensible defaults (release for available/under-offer berths, retain for sold berths and yachts, cancel for active reservations, leave for invoices and documents). - High-stakes deals show an amber warning panel + require a reason in the textarea before the Archive button enables. Signed-document acknowledgment checkbox blocks submission until checked. - Wires into client-detail-header in place of the previous dumb ArchiveConfirmDialog (the simple confirm dialog is kept for the restore case until the smart-restore wizard ships). - Pre-flight blocker banner surfaces dossier.blockers (e.g. active reservation on a sold berth) and disables the Archive button entirely. Two side fixes from CSP rollout: - next.config CSP allows unpkg.com in dev so the react-grab devtool loads. Stripped in prod via the existing isProd flag. - middleware whitelist now passes /manifest.json + icon-*.png + apple-touch-icon through unauthenticated, so PWA installability isn't blocked by the auth redirect. Bulk variant + restore wizard + hard-delete-with-email-code land in follow-on commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:19:34 +02:00
'/((?!_next/static|_next/image|favicon\\.ico|images/|manifest\\.json|icon-|apple-touch-icon).*)',
],
};