diff --git a/docs/api-reference/veryfront/security.md b/docs/api-reference/veryfront/security.md index e511d8dd59..b7aa492aa5 100644 --- a/docs/api-reference/veryfront/security.md +++ b/docs/api-reference/veryfront/security.md @@ -55,7 +55,7 @@ applySecurityHeaders(response.headers, false, generateNonce(), null); | `applyCORSHeaders` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/headers.ts#L86) | | `applyCORSHeadersSync` | Apply CORS synchronously. Promise-returning values still fail closed at runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/headers.ts#L112) | | `applyCsrfCookie` | Set CSRF cookie on GET/HEAD responses when not already present. Uses httpOnly: false so client JS can read the cookie for double-submit. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/csrf/helpers.ts#L150) | -| `applySecurityHeaders` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L343) | +| `applySecurityHeaders` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L445) | | `buildCacheControl` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/cache-handler.ts#L86) | | `cors` | Create CORS middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/middleware.ts#L10) | | `corsSimple` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/middleware.ts#L39) | @@ -66,7 +66,7 @@ applySecurityHeaders(response.headers, false, generateNonce(), null); | `createValidator` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/index.ts#L446) | | `generateCsrfToken` | Generate a CSRF token and return value + Set-Cookie header string | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/csrf/helpers.ts#L70) | | `generateNonce` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L71) | -| `getSecurityHeader` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L330) | +| `getSecurityHeader` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L432) | | `handleCORSPreflight` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/preflight.ts#L126) | | `isPreflightRequest` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/preflight.ts#L186) | | `isRequestBodyTooLargeError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/limits.ts#L100) | diff --git a/docs/guides/security-headers.md b/docs/guides/security-headers.md index 1896a9ed06..23ec39c31d 100644 --- a/docs/guides/security-headers.md +++ b/docs/guides/security-headers.md @@ -39,6 +39,25 @@ Three directives are worth understanding: - **`style-src` and `font-src` include the Google Fonts origins** because `veryfront/fonts` writes those tags into the document itself. Google Fonts therefore works with no configuration. If your project never uses it, see [Tightening the policy](#tightening-the-policy). - **`frame-ancestors`** is `'none'` on your own domain. On `*.veryfront.com` addresses it instead allows the Studio origins, so the Studio preview iframe works. +## What is enforced + +The policy above is served as `Content-Security-Policy-Report-Only`: browsers report what it would have blocked, and block nothing. + +Two directives are served enforced by default, in a second `Content-Security-Policy` header: + +- `object-src 'none'` blocks ``, `` and ``. +- `base-uri 'self'` blocks a `` element pointing at another origin. + +Both close injection routes, and neither can be widened: they are required directives, so `security.csp` cannot add sources to them or drop them. + +The exception is `VERYFRONT_CSP`. Setting that environment variable replaces the policy wholesale and serves it enforced on its own, so neither the reported floor nor this pair is added alongside it. Writing a whole policy by hand is an explicit act, and Veryfront does not second-guess it. + +**Every directive you give a value in `security.csp` is enforced too**, including `form-action` and `frame-ancestors`. Listing your image origins means you have thought about images, so `img-src` binds with your sources in it. Directives you never mentioned keep reporting, and so does one written as `undefined`, which counts as unconfigured rather than as a declaration. Adding one origin does not bind the rest of your policy, because deciding to allow a CDN and deciding to bind script execution across your site are different decisions. + +One consequence worth knowing: CSP resolves a missing directive by falling back to a broader one, so enforcing `script-src` would otherwise also constrain workers and frames. Veryfront emits those alongside it, with the same sources the reported policy gives them, so declaring one directive never tightens another behind your back. + +To bind a directive, configure it. To see what binding it would cost first, read the reports. + ## Violation reports The policy asks browsers to report what it blocks, to `/_vf/csp-report` on your own origin. Both spellings are sent because `report-to` is the current one and `report-uri` is still the only one several shipping browsers honour. diff --git a/src/security/http/response/security-handler.test.ts b/src/security/http/response/security-handler.test.ts index dc2029de77..e4b6d6d9df 100644 --- a/src/security/http/response/security-handler.test.ts +++ b/src/security/http/response/security-handler.test.ts @@ -1,7 +1,7 @@ import { CSP_REPORT_PATH } from "#veryfront/security/http/csp-report-endpoint.ts"; import "#veryfront/schemas/_test-setup.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { applySecurityHeaders, @@ -612,8 +612,10 @@ describe("security/http/response/security-handler", () => { * must not also assert the enforcement mode -- that is covered separately. */ const getCsp = (headers: Headers): string | null => - headers.get("Content-Security-Policy") ?? - headers.get("Content-Security-Policy-Report-Only"); + // Reported first: the enforced header carries only the directives that + // bind, so the full policy is the reported one whenever both are served. + headers.get("Content-Security-Policy-Report-Only") ?? + headers.get("Content-Security-Policy"); it("keeps the canonical policy-owned header list aligned with production output", () => { const headers = applyHeaders({ @@ -627,10 +629,11 @@ describe("security/http/response/security-handler", () => { for (const name of emitted) { assert(owned.includes(name as typeof owned[number]), `${name} must be policy-owned`); } + // Two now, deliberately: the reported floor and the subset that binds + // beside it. They are complementary rather than alternatives. assertEquals( - emitted.filter((n) => n.startsWith("content-security-policy")).length, - 1, - "exactly one CSP header, never both", + emitted.filter((n) => n.startsWith("content-security-policy")).sort(), + ["content-security-policy", "content-security-policy-report-only"], ); assertEquals( owned.filter((n) => !n.startsWith("content-security-policy")).sort(), @@ -644,8 +647,7 @@ describe("security/http/response/security-handler", () => { // that `Reporting-Endpoints` does not define makes the browser send // nothing at all, and the policy still looks correct in devtools. const headers = applyHeaders(); - const policy = headers.get("Content-Security-Policy") ?? - headers.get("Content-Security-Policy-Report-Only") ?? ""; + const policy = getCsp(headers) ?? ""; const group = /report-to ([^;]+)/.exec(policy)?.[1]?.trim(); assert(group, "policy must carry a report-to directive"); @@ -760,7 +762,11 @@ describe("security/http/response/security-handler", () => { headers.get("Content-Security-Policy-Report-Only"), buildCSP(false, "nonce", null), ); - assertEquals(headers.has("Content-Security-Policy"), false); + assertEquals( + headers.get("Content-Security-Policy"), + "object-src 'none'; base-uri 'self'", + "the directives safe to bind for everyone bind for everyone", + ); }); it("should not set CSP in dev mode when no CSP config", () => { @@ -769,44 +775,76 @@ describe("security/http/response/security-handler", () => { assertEquals(headers.has("Content-Security-Policy-Report-Only"), false); }); - it("enforces the policy once a project declares any csp config", () => { - // Declaring `security.csp` is the opt-in signal: the project has looked - // at its own policy, so the floor stops being advisory for it. + it("enforces a directive the project declared, and only that one", () => { + // Declaring a directive is taken as meaning it. Inferring from one image + // origin that the project also wants `script-src` bound across its site + // is a different claim, and not one it made. const headers = applyHeaders({ config: { csp: { imgSrc: ["https://cdn.example.com"] } } }); - assertEquals( - headers.get("Content-Security-Policy"), - buildCSP(false, "nonce", { - csp: { imgSrc: ["https://cdn.example.com"] }, - }), - ); - assertEquals(headers.has("Content-Security-Policy-Report-Only"), false); + + const enforced = headers.get("Content-Security-Policy") ?? ""; + assertStringIncludes(enforced, "img-src"); + assertStringIncludes(enforced, "https://cdn.example.com"); + assert(!enforced.includes("script-src"), "an undeclared directive keeps reporting"); + + // The full policy still reports, so violations stay visible. + assertStringIncludes(headers.get("Content-Security-Policy-Report-Only") ?? "", "script-src"); }); - it("treats an empty csp object as an opt-in", () => { - // The project touched the key, which is the signal -- not how much it put - // in it. Reading emptiness as "unconfigured" would leave a project that - // deliberately accepted the floor stuck in report-only forever. - const headers = applyHeaders({ config: { csp: {} } }); - assertEquals(headers.has("Content-Security-Policy"), true); - assertEquals(headers.has("Content-Security-Policy-Report-Only"), false); + it("does not tighten a directive that would fall back to an enforced one", () => { + // CSP resolves a missing directive through a fallback chain, so a policy + // with `script-src` and no `worker-src` constrains workers *by* + // `script-src`. Omitting the descendants would block the `blob:` workers + // the reported `worker-src` explicitly allows. + const headers = applyHeaders({ config: { csp: { scriptSrc: ["https://cdn.example.com"] } } }); + const enforced = headers.get("Content-Security-Policy") ?? ""; + const reported = headers.get("Content-Security-Policy-Report-Only") ?? ""; + + const directive = (policy: string, name: string) => + policy.split("; ").find((part) => part.startsWith(`${name} `)) ?? ""; + + for (const name of ["worker-src", "frame-src"]) { + assertEquals( + directive(enforced, name), + directive(reported, name), + `${name} must bind as it was reported, not as script-src`, + ); + } + assertStringIncludes(directive(enforced, "worker-src"), "blob:"); + }); + + it("treats an undefined directive value as unconfigured", () => { + // The merge skips `undefined`; the enforced set must agree, or a project + // writing `scriptSrc: undefined` binds a script-src it never configured. + const enforced = applyHeaders({ config: { csp: { scriptSrc: undefined } } }).get( + "Content-Security-Policy", + ) ?? ""; + assertEquals(enforced, "object-src 'none'; base-uri 'self'"); + }); + + it("binds only the always-enforced pair for an empty csp object", () => { + // Touching the key says nothing about which directives the project means. + const enforced = applyHeaders({ config: { csp: {} } }).get("Content-Security-Policy") ?? ""; + assertEquals(enforced, "object-src 'none'; base-uri 'self'"); + }); + + it("keeps the always-enforced pair even when a project tries to drop it", () => { + // `null` drops a directive's baseline sources, never the required ones, + // which is what stops a project from breaking its own site through + // config. Both of these are required, so both still bind. + const enforced = applyHeaders({ config: { csp: { objectSrc: null } } }).get( + "Content-Security-Policy", + ) ?? ""; + assertEquals(enforced, "object-src 'none'; base-uri 'self'"); }); it("reports rather than enforces for a project that configures other security keys", () => { // `security.cors` is not a CSP opt-in. This is the shape that made the // audit ambiguous: a `security` block exists, but no policy was tuned. const headers = applyHeaders({ config: { cors: true } }); - assertEquals(headers.has("Content-Security-Policy"), false); + assertEquals(headers.get("Content-Security-Policy"), "object-src 'none'; base-uri 'self'"); assert(headers.get("Content-Security-Policy-Report-Only") !== null); }); - it("enforces for everyone once VERYFRONT_CSP_ENFORCE is set", () => { - // The ops lever that ends the staged rollout. - const adapter = createMockAdapter({ VERYFRONT_CSP_ENFORCE: "1" }); - const headers = applyHeaders({ adapter }); - assertEquals(headers.get("Content-Security-Policy"), buildCSP(false, "nonce", null, adapter)); - assertEquals(headers.has("Content-Security-Policy-Report-Only"), false); - }); - it("enforces an ops-level VERYFRONT_CSP override even without project opt-in", () => { // Writing a whole policy by hand is already an explicit act; serving it // report-only would make the override do nothing. @@ -827,7 +865,7 @@ describe("security/http/response/security-handler", () => { }, }); assertEquals( - headers.get("Content-Security-Policy"), + getCsp(headers), buildCSP(false, "nonce", { csp: { imgSrc: ["https://cdn.example.com"] } }), ); }); @@ -843,7 +881,9 @@ describe("security/http/response/security-handler", () => { headers.get("Content-Security-Policy-Report-Only"), buildCSP(false, "nonce", { headers: {} } as SecurityConfig), ); - assertEquals(headers.has("Content-Security-Policy"), false); + // The enforced companion is platform-owned too, and is unaffected by + // whatever the project tried to set. + assertEquals(headers.get("Content-Security-Policy"), "object-src 'none'; base-uri 'self'"); }); it("still honors the override paths that are meant to exist", () => { @@ -921,14 +961,18 @@ describe("security/http/response/security-handler", () => { assertEquals(ctx.securityConfig.derivedCsp, { "img-src": ["https://cdn.real.example"] }); }); - it("serves the same policy either way, differing only in enforcement", () => { - // The report-only rollout must not weaken what is reported, or the - // violations a project sees would not predict what enforcement will do. - const reported = applyHeaders().get("Content-Security-Policy-Report-Only"); - const enforced = applyHeaders({ - adapter: createMockAdapter({ VERYFRONT_CSP_ENFORCE: "1" }), - }).get("Content-Security-Policy"); - assertEquals(reported, enforced); + it("serves the same policy whether or not a directive binds", () => { + // The bound subset is a slice of the reported policy, not a different + // policy. A project that declares `img-src` must see the same sources in + // both places, or the enforced header would be stricter than the one it + // was shown. + const headers = applyHeaders({ config: { csp: { imgSrc: ["https://cdn.example.com"] } } }); + const reported = headers.get("Content-Security-Policy-Report-Only") ?? ""; + const enforced = headers.get("Content-Security-Policy") ?? ""; + + const imgFrom = (policy: string) => + policy.split("; ").find((part) => part.startsWith("img-src ")) ?? ""; + assertEquals(imgFrom(enforced), imgFrom(reported)); }); it("should apply extra headers from config", () => { @@ -1001,7 +1045,7 @@ describe("security/http/response/security-handler", () => { }; const headers = applyHeaders({ config }); assertEquals( - headers.get("Content-Security-Policy"), + getCsp(headers), buildCSP(false, "nonce", config), ); }); @@ -1023,10 +1067,10 @@ describe("security/http/response/security-handler", () => { }; const headers = applyHeaders({ config }); assertEquals( - headers.get("Content-Security-Policy"), + getCsp(headers), buildCSP(false, "nonce", config), ); - const csp = headers.get("Content-Security-Policy") ?? ""; + const csp = getCsp(headers) ?? ""; assert( parseDirectiveSources(csp, "frame-src").includes("'self'"), "the floor's own frame-src survives the addition", @@ -1037,7 +1081,7 @@ describe("security/http/response/security-handler", () => { const config: SecurityConfig = { csp: {} }; const headers = applyHeaders({ config }); assertEquals( - headers.get("Content-Security-Policy"), + getCsp(headers), buildCSP(false, "nonce", config), ); }); diff --git a/src/security/http/response/security-handler.ts b/src/security/http/response/security-handler.ts index 3e0015b872..bd60d0fff5 100644 --- a/src/security/http/response/security-handler.ts +++ b/src/security/http/response/security-handler.ts @@ -288,41 +288,143 @@ export function buildCSP( } /** - * Whether the built policy is enforced or merely reported. + * Whether the whole policy is enforced. * - * The floor is a breaking change for any project that loads an asset the - * platform does not emit -- a stock-photo host, a video CDN, a social icon. - * Shipping it enforced blocked those assets on every such project at once, - * with no signal to the owner beyond a browser console, and no remedy that - * does not require republishing: `security.csp` lives in project config, and - * hosted projects serve config from their deployed release, so editing it - * changes nothing until the next publish. + * Only `VERYFRONT_CSP`, a full policy override, binds everything: setting it is + * an explicit ops act. Otherwise enforcement is per directive -- see + * {@link buildEnforcedCSP}. + */ +function isCspEnforced(hasEnvOverride: boolean): boolean { + return hasEnvOverride; +} + +/** + * Directives enforced for every project, whatever it configured. + * + * A report-only policy protects nothing, so the two directives safe to bind + * unconditionally are bound unconditionally. Both close real injection vectors + * and neither has a use a project would notice losing: `object-src 'none'` + * blocks ``/`` payloads, and `base-uri 'self'` blocks a `` + * tag rewriting every relative URL on the page. + * + * Deliberately excluded, because each breaks working sites: `form-action` + * (projects post forms to HubSpot and the like), `frame-ancestors` (projects + * are legitimately embedded), and `script-src` with the asset directives, which + * are the reason the floor reports rather than binds. + */ +const ALWAYS_ENFORCED_DIRECTIVES: ReadonlySet = new Set([ + "object-src", + "base-uri", +]); + +/** + * Directives that inherit from another when absent. + * + * CSP resolves a missing directive by walking a fallback chain, so a policy + * containing `script-src` but not `worker-src` does not leave workers + * unconstrained -- it constrains them *by* `script-src`. Emitting a subset of + * directives is therefore not the same as emitting those directives alone, and + * a companion policy that omitted the descendants would silently tighten them: + * a project declaring only `scriptSrc` would find its `blob:` workers blocked + * by an enforced `script-src` that never mentioned them, while the reported + * `worker-src` said they were fine. + * + * Keyed by the directive that would absorb the others. + */ +const CSP_FALLBACK_DEPENDENTS: ReadonlyMap = new Map([ + ["script-src", ["script-src-elem", "script-src-attr", "child-src", "worker-src", "frame-src"]], + ["child-src", ["worker-src", "frame-src"]], + ["style-src", ["style-src-elem", "style-src-attr"]], + ["default-src", [ + "script-src", + "script-src-elem", + "script-src-attr", + "style-src", + "style-src-elem", + "style-src-attr", + "img-src", + "font-src", + "connect-src", + "media-src", + "object-src", + "manifest-src", + "child-src", + "worker-src", + "frame-src", + ]], +]); + +/** Every directive that must travel with `names` so none is tightened by fallback. */ +function withFallbackDependents(names: Iterable): Set { + const closed = new Set(names); + // Fixed point: `default-src` pulls in `script-src`, which pulls in its own. + for (let changed = true; changed;) { + changed = false; + for (const name of [...closed]) { + for (const dependent of CSP_FALLBACK_DEPENDENTS.get(name) ?? []) { + if (!closed.has(dependent)) { + closed.add(dependent); + changed = true; + } + } + } + } + return closed; +} + +/** + * The enforced companion to the reported floor. + * + * Carries the always-enforced directives plus every directive the project + * declared, and then whatever those would otherwise absorb through CSP's + * fallback chain. Declaring a directive is taken as meaning it -- a project + * that lists its image origins wants `img-src` to hold -- while directives it + * never mentioned keep reporting rather than binding. That is the difference + * between honouring a project's configuration and inferring, from one image + * origin, that it also wants `script-src` bound across the site. + * + * Values come from the same merged policy the reported header carries, so a + * directive pulled in by fallback is enforced exactly as it was reported. * - * So the floor reports before it enforces. A project that has declared - * `security.csp` has demonstrably tuned its policy and gets the enforced - * header; everyone else gets `-Report-Only`, which surfaces the same - * violations without breaking the page. `VERYFRONT_CSP_ENFORCE` flips the - * default once adoption is high enough, and `VERYFRONT_CSP` (a full policy - * override) is always enforced because setting it is an explicit ops act. + * @returns the enforced policy, or "" when the full policy already binds */ -function isCspEnforced( - config: SecurityConfig | null | undefined, - adapter: RuntimeAdapter | undefined, - hasEnvOverride: boolean, -): boolean { - if (hasEnvOverride) return true; - if (adapter?.env?.get?.("VERYFRONT_CSP_ENFORCE")?.trim()) return true; - // An empty `csp` object is still an opt-in: the project touched the key. - return config?.csp !== undefined && config.csp !== null; +export function buildEnforcedCSP( + isDev: boolean, + nonce: string, + config?: SecurityConfig | null, + isVeryfrontDomain?: boolean, + hasEnvOverride = false, +): string { + if (isDev || isCspEnforced(hasEnvOverride)) return ""; + + // `undefined` means absent, exactly as the merge treats it. Without this a + // project writing `scriptSrc: undefined` would enforce the merged + // `script-src` it never configured. + const declared = Object.entries(config?.csp ?? {}) + .filter(([, value]) => value !== undefined) + .map(([key]) => toCspDirectiveName(key)); + + const binding = withFallbackDependents([...ALWAYS_ENFORCED_DIRECTIVES, ...declared]); + + const full = mergeCspDirectives( + requiredDirectives(nonce, isVeryfrontDomain ?? false), + config?.csp, + nonce, + config?.derivedCsp, + ); + + const enforced = Object.fromEntries( + Object.entries(full).filter(([name]) => binding.has(name)), + ); + + return Object.keys(enforced).length > 0 ? serializeDirectives(enforced) : ""; } /** Header name carrying the policy, per {@link isCspEnforced}. */ function cspHeaderName( - config?: SecurityConfig | null, - adapter?: RuntimeAdapter, hasEnvOverride = false, ): "Content-Security-Policy" | "Content-Security-Policy-Report-Only" { - return isCspEnforced(config, adapter, hasEnvOverride) + return isCspEnforced(hasEnvOverride) ? "Content-Security-Policy" : "Content-Security-Policy-Report-Only"; } @@ -381,7 +483,15 @@ export function applySecurityHeaders( const csp = buildCSP(isDev, nonce, config, adapter, isVeryfrontDomain); if (csp) { const hasEnvOverride = Boolean(adapter?.env?.get?.("VERYFRONT_CSP")?.trim()); - headers.set(cspHeaderName(config, adapter, hasEnvOverride), csp); + const headerName = cspHeaderName(hasEnvOverride); + headers.set(headerName, csp); + + // A report-only floor protects nothing on its own, so the directives that + // are safe to bind for everyone are served enforced beside it. + if (headerName === "Content-Security-Policy-Report-Only") { + const enforced = buildEnforcedCSP(isDev, nonce, config, isVeryfrontDomain, hasEnvOverride); + if (enforced) headers.set("Content-Security-Policy", enforced); + } // Names the group the policy's `report-to` refers to. Without this header // the directive names nothing and the browser sends no reports at all. headers.set( diff --git a/src/server/handlers/request/openapi-docs.handler.test.ts b/src/server/handlers/request/openapi-docs.handler.test.ts index f27ec7fc77..c44b260806 100644 --- a/src/server/handlers/request/openapi-docs.handler.test.ts +++ b/src/server/handlers/request/openapi-docs.handler.test.ts @@ -34,8 +34,10 @@ describe("server/handlers/request/openapi-docs.handler", () => { // Either header carries the policy: the floor is served report-only // until a project opts in, and this asserts nonce alignment either way. - const csp = response.headers.get("content-security-policy") ?? - response.headers.get("content-security-policy-report-only") ?? ""; + const csp = // Reported first: the enforced header carries only the directives that + // bind, and the nonce lives in the reported `script-src`. + response.headers.get("content-security-policy-report-only") ?? + response.headers.get("content-security-policy") ?? ""; const nonceMatch = csp.match(/nonce-([^' ;]+)/); assertEquals(Boolean(nonceMatch), true); diff --git a/src/server/handlers/request/rsc/index.test.ts b/src/server/handlers/request/rsc/index.test.ts index dd8882ec77..deb53808f3 100644 --- a/src/server/handlers/request/rsc/index.test.ts +++ b/src/server/handlers/request/rsc/index.test.ts @@ -63,8 +63,10 @@ describe("server/handlers/request/rsc", () => { const html = await response.text(); // Either header carries the policy: the floor is served report-only // until a project opts in, and this asserts nonce alignment either way. - const csp = response.headers.get("content-security-policy") ?? - response.headers.get("content-security-policy-report-only") ?? ""; + const csp = // Reported first: the enforced header carries only the directives that + // bind, and the nonce lives in the reported `script-src`. + response.headers.get("content-security-policy-report-only") ?? + response.headers.get("content-security-policy") ?? ""; const nonceMatch = csp.match(/nonce-([^' ;]+)/); assertEquals(Boolean(nonceMatch), true); diff --git a/src/server/handlers/request/static.handler.test.ts b/src/server/handlers/request/static.handler.test.ts index db22547b23..93bed73bc9 100644 --- a/src/server/handlers/request/static.handler.test.ts +++ b/src/server/handlers/request/static.handler.test.ts @@ -217,8 +217,10 @@ describe("server/handlers/request/static.handler", () => { const body = await response.text(); // Either header carries the policy: the floor is served report-only // until a project opts in, and this asserts nonce alignment either way. - const csp = response.headers.get("content-security-policy") ?? - response.headers.get("content-security-policy-report-only") ?? ""; + const csp = // Reported first: the enforced header carries only the directives that + // bind, and the nonce lives in the reported `script-src`. + response.headers.get("content-security-policy-report-only") ?? + response.headers.get("content-security-policy") ?? ""; const nonceMatch = csp.match(/nonce-([^' ;]+)/); assertEquals(Boolean(nonceMatch), true); diff --git a/tests/integration/server/production-server.test.ts b/tests/integration/server/production-server.test.ts index 5dc4689769..fb2c0369b3 100644 --- a/tests/integration/server/production-server.test.ts +++ b/tests/integration/server/production-server.test.ts @@ -37,9 +37,16 @@ import { deleteEnv, getHostEnv, setEnv } from "../../../src/platform/compat/proc * instead. These fixtures declare none of those; the assertions below are about * policy content and nonce alignment, not delivery mode. */ +/** + * The complete policy, whichever header carries it. + * + * Both are served: the reported header carries the whole policy, and the + * enforced one carries only the directives that bind. Callers here assert on + * policy content, so the reported header is the one they want. + */ function readCsp(headers: Headers): string | null { - return headers.get("content-security-policy") ?? - headers.get("content-security-policy-report-only"); + return headers.get("content-security-policy-report-only") ?? + headers.get("content-security-policy"); } function escapeRegExp(value: string): string {