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:
@@ -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", () => {
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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("; ");
|
||||
|
||||
@@ -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-<random>'` | 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 `<object>` / 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 `<base>` 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 `<style>` blocks that
|
||||
cannot carry a nonce. A strict `style-src-elem` would break both. The risk is
|
||||
bounded because `script-src` is nonce-based — a pure CSS-injection attack
|
||||
cannot escalate to JS execution in this application.
|
||||
|
||||
## 8. Rate Limiting
|
||||
|
||||
- **Per-IP rate limiting**: via middleware on all API routes
|
||||
|
||||
Reference in New Issue
Block a user