Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/process-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went
- **P0 carry-over (PR #278 / `cursor/audit-p2-p3-hardening-b54f`):** RAG cache owner/indexing-version guards, synopsis parity in ranking/detectors, DELETE TOCTOU re-check, worker cache invalidation on job completion, and `RAG_QUERY_HASH_SECRET` required in production-like readiness checks.
- **P2 M13:** `20260702000000_commit_generation_preserve_legacy_artifacts.sql` must be applied to live Supabase before reindex commits can safely purge legacy NULL-generation rows. After apply, run `npm run check:m13-migration`, `npm run reindex:health`, and `npm run check:indexing`. `search_schema_health()` now reports `commit_document_index_generation.preserve_legacy_artifacts_migration` when the live function body is stale.
- **P2 upload hardening:** `/api/upload` consumes the `document_upload` rate-limit bucket (12/min owner, 3/min anonymous).
- **P3 dispositioned (no code change):** L9 searchable-only `image_count` (documented in `worker/main.ts`); L11 triple `readFile` peak-memory trade-off (documented at the ingestion site); L18 duplicate `audit_logs` policy in an already-applied migration (do not edit applied migrations — consolidate only if migrations are ever squashed); L19 CSP `script-src 'unsafe-inline'` deferred (no active XSS sink today; nonce migration needs dedicated UI verification).
- **P3 dispositioned (no code change):** L9 searchable-only `image_count` (documented in `worker/main.ts`); L11 triple `readFile` peak-memory trade-off (documented at the ingestion site); L18 duplicate `audit_logs` policy in an already-applied migration (do not edit applied migrations — consolidate only if migrations are ever squashed).
- **L19 RESOLVED — CSP `script-src` nonce migration (branch `claude/csp-nonce-migration`).** Production `script-src` is now `'self' 'nonce-<per-request>' 'strict-dynamic'` (no `'unsafe-inline'`, no `'unsafe-eval'`); the nonce is generated per request in `src/proxy.ts`, threaded into SSR via the `x-nonce` + CSP request headers, and applied to Next's own bootstrap/bundle/flight scripts automatically plus the one hand-authored inline script (theme-flash guard in `src/app/layout.tsx`). Every other CSP directive and security header is unchanged; the static (non-CSP) headers still come from `next.config.ts` → `buildSecurityHeaders`. **Development keeps the pre-migration `'self' 'unsafe-inline' 'unsafe-eval'` (no `'strict-dynamic'`)** because the Turbopack dev server injects non-nonced HMR/route-chunk `<script>` tags that `'strict-dynamic'` would block. **Dedicated UI verification (why L19 was deferred):** `npm run build`, full-Chromium `npm run verify:ui`, and a scripted prod-server (`next start`) console sweep of answer / search / document-viewer / auth flows confirming **zero** `securitypolicyviolation`. That sweep surfaced a pre-existing (already blocked under the old prod policy) benign eval violation from Zod 4's JIT probe (`new Function("")` in a swallowed try/catch); it is silenced by setting Zod `jitless` on the client in `src/instrumentation-client.ts` (server keeps JIT — no CSP there).

## Design convergence & type-scale ratchet (2026-07-06)

Expand Down
8 changes: 4 additions & 4 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { NextConfig } from "next";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildSecurityHeaders } from "./src/lib/security-headers";
import { buildSecurityHeaders, resolveRuntimeFlags } from "./src/lib/security-headers";

const isDevelopment = process.env.NODE_ENV === "development";
const isLocalHttpRuntime = isDevelopment || process.env.PLAYWRIGHT_BASE_URL?.startsWith("http://localhost:");
const projectRoot = path.dirname(fileURLToPath(import.meta.url));

const securityHeaders = buildSecurityHeaders({ isDevelopment, isLocalHttpRuntime: Boolean(isLocalHttpRuntime) });
// Static (non-CSP) headers for every route. The nonce'd CSP is emitted per
// request from src/proxy.ts; both derive their runtime flags from the same helper.
const securityHeaders = buildSecurityHeaders(resolveRuntimeFlags());

const nextConfig: NextConfig = {
devIndicators: false,
Expand Down
13 changes: 12 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { AuthProvider } from "@/lib/supabase/client";
import { WebVitalsReporter } from "@/components/web-vitals-reporter";
import "./globals.css";
Expand Down Expand Up @@ -36,11 +37,17 @@ export const viewport: Viewport = {
],
};

export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// Per-request CSP nonce set by src/proxy.ts. Next.js stamps its own scripts
// automatically, but the hand-authored theme-flash <script> below is ours, so
// it must carry the nonce explicitly or the strict script-src blocks it (a
// silent runtime failure: theme flash returns). Reading headers() opts the app
// into dynamic rendering — inherent to nonce-based CSP.
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html
lang="en"
Expand All @@ -53,6 +60,10 @@ export default function RootLayout({
Mirrors resolveThemePreference in src/lib/theme.ts: stored choice wins,
otherwise the OS preference. Key must match use-theme.ts. */}
<script
nonce={nonce}
// Next.js strips the nonce from the client payload (so scripts can't
// read it), which reads as a hydration mismatch on this attribute.
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("clinical-kb-theme");var d=t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",d);}catch(e){}})();`,
}}
Expand Down
14 changes: 14 additions & 0 deletions src/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Runs on the client after the HTML loads but before React hydration (see
// node_modules/next/dist/docs/.../instrumentation-client.md), so this preempts
// the first client-side Zod schema compile.
//
// Why: the production CSP (src/lib/security-headers.ts) has no 'unsafe-eval'.
// Zod 4's JIT compiler probes for eval with `new Function("")` inside a try/catch
// (node_modules/zod/src/v4/core/util.ts) — the throw is swallowed and validation
// still works, but the browser reports the caught eval as a
// `securitypolicyviolation` on every page. Disabling JIT skips the probe entirely
// (validation stays correct, just interpreted rather than compiled). The server
// has no CSP, so it keeps the faster JIT path — this is client-only by design.
import { config } from "zod";

config({ jitless: true });
46 changes: 43 additions & 3 deletions src/lib/security-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,46 @@ export type SecurityHeaderFlags = {
isLocalHttpRuntime: boolean;
};

export function buildContentSecurityPolicy({ isDevelopment, isLocalHttpRuntime }: SecurityHeaderFlags): string {
const scriptSrc = `script-src 'self' 'unsafe-inline'${isDevelopment ? " 'unsafe-eval'" : ""}; `;
// Single source of truth for the runtime flags that shape the policy. Both the
// static headers (next.config.ts) and the per-request nonce CSP (src/proxy.ts)
// derive from this, so the HSTS/upgrade-insecure-requests gating stays in lockstep
// with the script-src gating instead of drifting between two hand-copied copies.
export function resolveRuntimeFlags(): SecurityHeaderFlags {
const isDevelopment = process.env.NODE_ENV === "development";
return {
isDevelopment,
isLocalHttpRuntime: isDevelopment || process.env.PLAYWRIGHT_BASE_URL?.startsWith("http://localhost:") === true,
};
}

export type ContentSecurityPolicyOptions = SecurityHeaderFlags & {
// Per-request nonce (base64) generated in src/proxy.ts. script-src allow-lists
// this nonce instead of 'unsafe-inline', so only scripts carrying it execute.
nonce: string;
};

export function buildContentSecurityPolicy({
isDevelopment,
isLocalHttpRuntime,
nonce,
}: ContentSecurityPolicyOptions): string {
// Production: nonce + 'strict-dynamic' is the modern strict-CSP shape. CSP3
// browsers ignore host allow-lists AND 'unsafe-inline' for scripts, running
// only the nonce'd bootstrap (and scripts it loads). Next.js reads the nonce
// from the request CSP header and stamps its own framework/bundle/flight
// scripts automatically; the one hand-authored inline script (theme-flash guard
// in app/layout.tsx) carries the nonce explicitly via the `x-nonce` header.
//
// Development: keep 'unsafe-inline' + 'unsafe-eval' and NO 'strict-dynamic'.
// The Turbopack dev server injects HMR/runtime and route-chunk <script src>
// tags that are not nonce-tagged; 'strict-dynamic' would disable the 'self'
// allow-list and block every one of them (blank page + broken interactions).
// Dev is not the shipped security boundary, so it retains the pre-migration
// policy. style-src keeps 'unsafe-inline' in both (Next's font + inline styles
// are not nonce-tagged); only production script-src is nonce-gated.
const scriptSrc = isDevelopment
? "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
: `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; `;
const upgradeInsecureRequests = isLocalHttpRuntime ? "" : "upgrade-insecure-requests; ";

return (
Expand All @@ -43,6 +81,9 @@ export function buildContentSecurityPolicy({ isDevelopment, isLocalHttpRuntime }
);
}

// Static security headers applied to every route via next.config.ts. NOTE:
// Content-Security-Policy is intentionally NOT here — it carries a per-request
// nonce and is emitted from src/proxy.ts (see buildContentSecurityPolicy).
export function buildSecurityHeaders(flags: SecurityHeaderFlags): SecurityHeader[] {
return [
{ key: "X-Content-Type-Options", value: "nosniff" },
Expand All @@ -53,7 +94,6 @@ export function buildSecurityHeaders(flags: SecurityHeaderFlags): SecurityHeader
// No Cross-Origin-Embedder-Policy — see module header note.
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=()" },
{ key: "Origin-Agent-Cluster", value: "?1" },
{ key: "Content-Security-Policy", value: buildContentSecurityPolicy(flags) },
{ key: "X-Permitted-Cross-Domain-Policies", value: "none" },
...(flags.isLocalHttpRuntime
? []
Expand Down
55 changes: 45 additions & 10 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,87 @@ import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

import { env } from "@/lib/env";
import { buildContentSecurityPolicy, resolveRuntimeFlags } from "@/lib/security-headers";

// Next 16 renamed the `middleware` file convention to `proxy` (see
// node_modules/next/dist/docs/.../file-conventions/proxy.md). Proxy defaults to
// the Node.js runtime, which the Supabase client requires.
//
// Purpose: keep the user's @supabase/ssr session cookie fresh on navigation and
// API calls so persistent logins survive refreshes. It is a no-op unless the
// public Supabase env is configured AND an `sb-` auth cookie is present, so
// demo / local-no-auth traffic is untouched and adds no auth round-trip.
// Two jobs:
// 1. Content-Security-Policy nonce. A fresh per-request nonce is generated and
// threaded into the SSR request (`x-nonce` + the CSP header, which Next.js
// parses to stamp its framework/bundle scripts) and onto the response so the
// browser enforces it. This is why the CSP header lives here and not in
// next.config.ts: a nonce cannot be a build-time constant. Using a nonce
// opts pages into dynamic rendering (app/layout.tsx reads the nonce), which
// is inherent to nonce-based CSP.
// 2. Session refresh. Keep the user's @supabase/ssr session cookie fresh on
// navigation and API calls so persistent logins survive refreshes. It is a
// no-op unless the public Supabase env is configured AND an `sb-` auth
// cookie is present, so demo / local-no-auth traffic is untouched.

const documentFlowRedirects: Record<string, string> = {
"/mockups/document-search-command": "/documents/search",
"/mockups/document-search/source": "/documents/source",
"/mockups/document-search/source/evidence": "/documents/source/evidence",
};

// Same runtime flags next.config.ts uses for the static headers, so the nonce'd
// CSP matches the rest of the policy (unsafe-eval in dev, HTTPS upgrade off local
// http). Evaluated once at module load.
const { isDevelopment, isLocalHttpRuntime } = resolveRuntimeFlags();

export async function proxy(request: NextRequest) {
// A fresh, unguessable nonce per request (see Next.js CSP guide). Buffer+base64
// matches the documented pattern and keeps the value header-safe.
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const csp = buildContentSecurityPolicy({ isDevelopment, isLocalHttpRuntime, nonce });

// Request headers Next.js reads during SSR: `x-nonce` for our own inline
// <script>, and the CSP header from which Next extracts the nonce for its
// scripts. Rebuilt from the *current* request each call so session-cookie
// mutations below still propagate to the render.
const requestHeadersWithNonce = () => {
const headers = new Headers(request.headers);
headers.set("x-nonce", nonce);
headers.set("content-security-policy", csp);
return headers;
};
// Every response the browser sees must carry the enforced CSP header.
const withCsp = (response: NextResponse) => {
response.headers.set("content-security-policy", csp);
return response;
};

const { pathname } = request.nextUrl;
const redirectTarget = documentFlowRedirects[pathname];

if (redirectTarget) {
const url = request.nextUrl.clone();
url.pathname = redirectTarget;
return NextResponse.redirect(url);
return withCsp(NextResponse.redirect(url));
}

if (pathname.startsWith("/mockups") && process.env.NODE_ENV === "production") {
return new NextResponse(null, { status: 404 });
return withCsp(new NextResponse(null, { status: 404 }));
}

const url = env.NEXT_PUBLIC_SUPABASE_URL;
const key = env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
const hasAuthCookie = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-"));
if (!url || !key || !hasAuthCookie) {
return NextResponse.next({ request });
return withCsp(NextResponse.next({ request: { headers: requestHeadersWithNonce() } }));
}

let response = NextResponse.next({ request });
let response = NextResponse.next({ request: { headers: requestHeadersWithNonce() } });
const supabase = createServerClient(url, key, {
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
for (const { name, value } of cookiesToSet) request.cookies.set(name, value);
response = NextResponse.next({ request });
response = NextResponse.next({ request: { headers: requestHeadersWithNonce() } });
for (const { name, value, options } of cookiesToSet) response.cookies.set(name, value, options);
},
},
Expand All @@ -57,7 +92,7 @@ export async function proxy(request: NextRequest) {
// between createServerClient and getUser — a stale token here would sign the
// user out on the next request.
await supabase.auth.getUser();
return response;
return withCsp(response);
}

export const config = {
Expand Down
67 changes: 67 additions & 0 deletions tests/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";
import { proxy } from "../src/proxy";

// The proxy owns the per-request nonce CSP (see src/proxy.ts). CI's verify:ui
// only exercises the *dev* CSP path (Turbopack keeps 'unsafe-inline'); these
// unit tests run under NODE_ENV=test, so buildContentSecurityPolicy takes its
// production branch — this is the only automated coverage of the strict,
// shipped nonce policy. With no Supabase env configured the proxy short-circuits
// on the "no auth cookie" path, which is exactly where the nonce/CSP wiring runs.

function requestFor(path = "/"): NextRequest {
return new NextRequest(new URL(`http://localhost${path}`));
}

function scriptSrcOf(csp: string): string {
const directive = csp.split(";").find((d) => d.trim().startsWith("script-src"));
if (!directive) throw new Error(`no script-src in CSP: ${csp}`);
return directive.trim();
}

describe("proxy content-security-policy", () => {
it("emits a per-request nonce with strict-dynamic and no unsafe-inline (production shape)", async () => {
const res = await proxy(requestFor("/"));
const csp = res.headers.get("content-security-policy");
expect(csp).toBeTruthy();

const scriptSrc = scriptSrcOf(csp!);
expect(scriptSrc).toMatch(/'nonce-[A-Za-z0-9+/=_-]+'/);
expect(scriptSrc).toContain("'strict-dynamic'");
expect(scriptSrc).not.toContain("'unsafe-inline'");
expect(scriptSrc).not.toContain("'unsafe-eval'");
});

it("preserves the other CSP directives unchanged", async () => {
const csp = (await proxy(requestFor("/"))).headers.get("content-security-policy")!;
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("object-src 'none'");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("img-src 'self' data: blob: https:");
expect(csp).toContain("connect-src 'self' https://*.supabase.co https://api.openai.com");
expect(csp).toContain("style-src 'self' 'unsafe-inline'");
});

it("generates a fresh, unguessable nonce on every request", async () => {
const nonces = new Set<string>();
for (let i = 0; i < 5; i += 1) {
const csp = (await proxy(requestFor("/"))).headers.get("content-security-policy")!;
const nonce = csp.match(/'nonce-([^']+)'/)![1];
expect(nonce.length).toBeGreaterThanOrEqual(16);
nonces.add(nonce);
}
expect(nonces.size).toBe(5);
});

it("threads the same nonce into the SSR request headers (x-nonce)", async () => {
const res = await proxy(requestFor("/"));
const cspNonce = res.headers.get("content-security-policy")!.match(/'nonce-([^']+)'/)![1];

// NextResponse.next({ request: { headers } }) forwards overridden request
// headers back through the response via x-middleware-request-* so the SSR
// render sees x-nonce. Assert the forwarded nonce matches the enforced CSP.
const overridden = res.headers.get("x-middleware-override-headers") ?? "";
expect(overridden).toContain("x-nonce");
expect(res.headers.get("x-middleware-request-x-nonce")).toBe(cspNonce);
});
});
Loading
Loading