security: tighten CSP — drop provider wildcards, add object/frame/worker-src (#45)

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 09:08:40 +02:00
parent b32160d546
commit d1075af77d
4 changed files with 68 additions and 3 deletions
+1 -1
View File
@@ -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", () => {
+27
View File
@@ -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)", () => {
+13 -1
View File
@@ -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("; ");