diff --git a/docs/security-model.md b/docs/security-model.md index d740b2e..b5b28f9 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -11,7 +11,7 @@ This page describes what the code enforces today. Settings that live only in AWS - `DELETE /api/account?confirm=true` and the guest purge both use `eraseTeam()` (`src/lib/erase-team.ts`). It removes every version and delete marker under the team's S3 prefixes, then deletes the database rows. If the S3 step fails, it stops before touching the database. The guest purge keeps `bedrock_usage` rows so that the shared AI cap and cost reporting stay correct. - **AI.** Uploaded content is sent to Amazon Bedrock inside `` tags, with an instruction to treat it as data. This reduces prompt-injection risk but does not prevent it, which is why rules need approval or a committee vote. `checkAiBudget` (`src/lib/bedrock-meter.ts`) runs in Postgres before every Bedrock call: suggest-transforms, the auto-clean committee, the chat builder and synthetic data. Every call is metered in `bedrock_usage`. Costs are estimates based on the prices in `src/lib/ai-config.ts`. - **Rate limits.** Upstash sliding windows per user: 20 uploads/h, 50 AI calls/h and 30 chat messages/h. They fail open when Redis is not configured. The guest limits below are enforced in Postgres and do not depend on Upstash. -- **Headers.** `next.config.ts` sets a CSP (`src/lib/csp.ts`). For scripts it allows `'unsafe-inline'` and `'unsafe-eval'`, which Next.js and Clerk need without nonces. It adds the Cloudflare Turnstile origins when Turnstile is configured. +- **Headers.** `next.config.ts` sets a CSP (`src/lib/csp.ts`). For scripts it allows `'unsafe-inline'` and `'unsafe-eval'`, which Next.js and Clerk need without nonces. `worker-src 'self' blob:` lets Clerk start its blob: Web Workers. It adds the Cloudflare Turnstile origins when Turnstile is configured. - **Secrets.** Nothing secret is committed, and `.env.example` contains only placeholders. AWS account ids and ARNs from older commits remain in git history. ## Guest access diff --git a/src/lib/csp.test.ts b/src/lib/csp.test.ts index 008235c..48e42a1 100644 --- a/src/lib/csp.test.ts +++ b/src/lib/csp.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildCsp } from "@/lib/csp"; -// The exact policy shipped before the CSP moved out of next.config.ts. +// The exact production policy (no test flag, no Turnstile). const PRODUCTION_CSP = [ "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://clerk.com https://*.clerk.accounts.dev https://js.sentry-cdn.com https://*.sentry.io", @@ -10,6 +10,7 @@ const PRODUCTION_CSP = [ "img-src 'self' data: blob: https:", "connect-src 'self' https://*.sentry.io https://*.ingest.sentry.io https://*.clerk.accounts.dev https://*.clerk.com wss://*.clerk.accounts.dev https://sqs.us-east-1.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-1.amazonaws.com", "frame-src 'none'", + "worker-src 'self' blob:", "object-src 'none'", "base-uri 'self'", "form-action 'self'", @@ -17,7 +18,7 @@ const PRODUCTION_CSP = [ ].join("; "); describe("buildCsp", () => { - it("is byte-identical to the previous production policy without the test flag", () => { + it("is byte-identical to the production policy without the test flag", () => { expect(buildCsp({})).toBe(PRODUCTION_CSP); }); @@ -31,6 +32,10 @@ describe("buildCsp", () => { expect(csp).not.toContain("upgrade-insecure-requests"); }); + it("allows Clerk's blob: workers, and only from self or blob:", () => { + expect(buildCsp({})).toContain("; worker-src 'self' blob:;"); + }); + it("allows Cloudflare Turnstile only when its site key is set", () => { const csp = buildCsp({ NEXT_PUBLIC_TURNSTILE_SITE_KEY: "0x4AAAAAAA" }); expect(csp).toContain("frame-src https://challenges.cloudflare.com"); diff --git a/src/lib/csp.ts b/src/lib/csp.ts index 4a658f9..4a2ae5c 100644 --- a/src/lib/csp.ts +++ b/src/lib/csp.ts @@ -49,6 +49,9 @@ export function buildCsp(env: EnvSource = process.env): string { "img-src 'self' data: blob: https:", `connect-src ${connectSrc.join(" ")}`, turnstile ? `frame-src ${TURNSTILE_ORIGIN}` : "frame-src 'none'", + // Clerk starts Web Workers from blob: URLs; without this the browser + // falls back to script-src (no blob:) and logs a CSP violation. + "worker-src 'self' blob:", "object-src 'none'", "base-uri 'self'", "form-action 'self'",