From d1075af77d3d929ee2f48327d6e26d9c60f8e4f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 17 Apr 2026 09:08:40 +0200 Subject: [PATCH] =?UTF-8?q?security:=20tighten=20CSP=20=E2=80=94=20drop=20?= =?UTF-8?q?provider=20wildcards,=20add=20object/frame/worker-src=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser code never calls OpenAI/Azure/Gemini directly; all AI traffic is server-side tRPC. connect-src is now locked to 'self'. Added object-src 'none', frame-src 'none', media-src 'self', and worker-src 'self' blob:. style-src keeps 'unsafe-inline' for React + @react-pdf/renderer (documented residual risk — script-src is nonce-based so CSS injection cannot escalate to JS). Added three regression tests covering connect-src no-wildcards, object/frame-src 'none', and worker-src scope. Co-Authored-By: Claude Opus 4.7 --- apps/web/src/lib/cron-auth.test.ts | 2 +- apps/web/src/middleware.test.ts | 27 +++++++++++++++++++++++++++ apps/web/src/middleware.ts | 14 +++++++++++++- docs/security-architecture.md | 28 +++++++++++++++++++++++++++- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/cron-auth.test.ts b/apps/web/src/lib/cron-auth.test.ts index 359c691..0269b27 100644 --- a/apps/web/src/lib/cron-auth.test.ts +++ b/apps/web/src/lib/cron-auth.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { verifyCronSecret } from "./cron-auth.js"; describe("verifyCronSecret — fail-closed when CRON_SECRET missing", () => { diff --git a/apps/web/src/middleware.test.ts b/apps/web/src/middleware.test.ts index df52416..c105119 100644 --- a/apps/web/src/middleware.test.ts +++ b/apps/web/src/middleware.test.ts @@ -80,6 +80,33 @@ describe("middleware — Content-Security-Policy", () => { expect(csp).toContain("frame-ancestors 'none'"); } }); + + it("connect-src has no wildcards — browser cannot call external hosts directly", async () => { + const middleware = await importMiddleware("production"); + const res = await middleware(new NextRequest("http://localhost:3100/")); + const csp = res.headers.get("Content-Security-Policy") ?? ""; + const connectSrc = csp.split(";").find((d: string) => d.trim().startsWith("connect-src")) ?? ""; + expect(connectSrc).toMatch(/connect-src\s+'self'\s*$/); + expect(connectSrc).not.toContain("*"); + expect(connectSrc).not.toContain("openai.com"); + expect(connectSrc).not.toContain("azure.com"); + expect(connectSrc).not.toContain("googleapis.com"); + }); + + it("object-src, frame-src are 'none' to block legacy plugin and iframe vectors", async () => { + const middleware = await importMiddleware("production"); + const res = await middleware(new NextRequest("http://localhost:3100/")); + const csp = res.headers.get("Content-Security-Policy") ?? ""; + expect(csp).toContain("object-src 'none'"); + expect(csp).toContain("frame-src 'none'"); + }); + + it("worker-src restricts web workers to same-origin and blob: (for Next.js)", async () => { + const middleware = await importMiddleware("production"); + const res = await middleware(new NextRequest("http://localhost:3100/")); + const csp = res.headers.get("Content-Security-Policy") ?? ""; + expect(csp).toContain("worker-src 'self' blob:"); + }); }); describe("middleware — API allowlist (default-deny)", () => { diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 9c5dd97..6f191e9 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -32,6 +32,10 @@ function isPublicUiPath(pathname: string): boolean { return PUBLIC_UI_PREFIXES.some((prefix) => pathname.startsWith(prefix)); } +// Browser-side code never talks to AI providers directly — every OpenAI / +// Azure / Gemini call goes through a server tRPC route. Therefore connect-src +// is locked to 'self' with no wildcards (ticket #45). If a future feature +// needs a browser-originated cross-origin request, add it explicitly here. function buildCsp(nonce: string, isProd: boolean): string { const scriptSrc = isProd ? `'self' 'nonce-${nonce}'` : `'self' 'unsafe-eval' 'unsafe-inline'`; @@ -40,11 +44,19 @@ function buildCsp(nonce: string, isProd: boolean): string { return [ "default-src 'self'", `script-src ${scriptSrc}`, + // style-src keeps 'unsafe-inline' because React inlines styles from + // component-scoped CSS and @react-pdf/renderer emits inline style blocks. + // A nonce-based style-src-elem breaks both. This is an accepted residual + // risk documented in docs/security-architecture.md §5. "style-src 'self' 'unsafe-inline'", `img-src ${imgSrc}`, "font-src 'self' data:", - "connect-src 'self' https://generativelanguage.googleapis.com https://*.openai.com https://*.azure.com", + "connect-src 'self'", "frame-ancestors 'none'", + "frame-src 'none'", + "object-src 'none'", + "media-src 'self'", + "worker-src 'self' blob:", "base-uri 'self'", "form-action 'self'", ].join("; "); diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 98712f6..ea6fd40 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -137,7 +137,9 @@ injection attempts and to surface them as audit-log entries. ## 7. HTTP Security Headers -Configured in `next.config.ts`: +Static headers are configured in `next.config.ts`. The Content-Security-Policy +is emitted per-request by `apps/web/src/middleware.ts` so it can carry a +per-request nonce. | Header | Value | | ------------------------- | ---------------------------------------------- | @@ -149,6 +151,30 @@ Configured in `next.config.ts`: | Referrer-Policy | `strict-origin-when-cross-origin` | | Permissions-Policy | Camera, microphone, geolocation disabled | +### Content-Security-Policy directives (production) + +| Directive | Value | Rationale | +| ----------------- | ------------------------- | -------------------------------------------------- | +| `default-src` | `'self'` | Baseline deny-all-cross-origin. | +| `script-src` | `'self' 'nonce-'` | No `unsafe-inline` / `unsafe-eval` in prod. | +| `style-src` | `'self' 'unsafe-inline'` | Accepted residual risk — see note below. | +| `img-src` | `'self' data: blob:` | Allow base64 previews and generated blobs only. | +| `font-src` | `'self' data:` | Data URLs for inline-embedded fonts. | +| `connect-src` | `'self'` | All AI / third-party calls are server-side. | +| `frame-ancestors` | `'none'` | Clickjacking defence. | +| `frame-src` | `'none'` | No third-party iframes. | +| `object-src` | `'none'` | Blocks legacy `` / Flash / applet vectors. | +| `media-src` | `'self'` | No cross-origin video / audio. | +| `worker-src` | `'self' blob:` | Next.js runtime uses blob-URL workers. | +| `base-uri` | `'self'` | Blocks `` hijacks. | +| `form-action` | `'self'` | Blocks form-exfiltration to third parties. | + +**Residual risk — `style-src 'unsafe-inline'`:** React inlines component-scoped +style attributes and `@react-pdf/renderer` emits inline `