From 0d206e1829b6676cc1d2dbebcbb787f305a03fbf Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 9 Aug 2026 07:21:47 +0200 Subject: [PATCH 1/3] feat(security): enforce what a project declared, report what it did not Enforcement was all-or-nothing and hinged on whether `security.csp` existed at all. So a project adding one image origin to unblock an asset also bound `script-src` across its site, and would break the moment it loaded a third-party script it had never listed. Allowing a CDN and binding script execution are different decisions and should not share a lever. They are separated per directive now. A declared directive is enforced, with the project's own sources in it; a directive the project never mentioned keeps reporting. Both policies are served together, which browsers apply independently, so violations stay visible for everything that does not yet bind. `object-src 'none'` and `base-uri 'self'` are enforced for every project regardless. Both close real injection routes -- `` payloads and a `` tag rewriting every relative URL -- and neither has a use a site would notice losing. `form-action` and `frame-ancestors` are deliberately excluded: projects post forms to third parties and are legitimately embedded. Removes VERYFRONT_CSP_ENFORCE. It existed to end a staged rollout that is not going to happen: derivation covers `img-src`, `media-src` and `font-src`, and cannot cover `script-src`, `connect-src` or `frame-src`, because deriving script origins from source would grant execution to whatever a scan turned up. A flag that will never be turned on is scaffolding implying a plan that does not exist. `VERYFRONT_CSP` remains as the ops-level full override. No project-level opt-in flag was added: nothing needs one yet, and the day something does is the day to design it against a real requirement. --- docs/api-reference/veryfront/security.md | 4 +- docs/guides/security-headers.md | 10 ++ .../http/response/security-handler.test.ts | 111 ++++++++++-------- .../http/response/security-handler.ts | 101 +++++++++++----- .../request/openapi-docs.handler.test.ts | 6 +- src/server/handlers/request/rsc/index.test.ts | 6 +- .../handlers/request/static.handler.test.ts | 6 +- 7 files changed, 160 insertions(+), 84 deletions(-) diff --git a/docs/api-reference/veryfront/security.md b/docs/api-reference/veryfront/security.md index e511d8dd59..d2ee26793a 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#L382) | | `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#L369) | | `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..dbc383d912 100644 --- a/docs/guides/security-headers.md +++ b/docs/guides/security-headers.md @@ -39,6 +39,16 @@ 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, in a second `Content-Security-Policy` header, for every project: `object-src 'none'` and `base-uri 'self'`. Both close injection routes that no ordinary site uses, so they cost you nothing and hold from the start. + +Anything you declare in `security.csp` is enforced too. 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. 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. + +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..988415ac5d 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,45 @@ 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("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 +834,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 +850,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 +930,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 +1014,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 +1036,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 +1050,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..3d1b9e937a 100644 --- a/src/security/http/response/security-handler.ts +++ b/src/security/http/response/security-handler.ts @@ -288,41 +288,80 @@ 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", +]); + +/** + * The enforced companion to the reported floor. + * + * Carries the always-enforced directives plus every directive the project + * declared. Declaring one is taken as meaning it -- a project that lists its + * image origins wants `img-src` to hold -- while the 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. * - * 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 ""; + + const declared = new Set( + Object.keys(config?.csp ?? {}).map((key) => toCspDirectiveName(key)), + ); + const binding = new Set([...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 +420,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); From 4b26554f7667a3c12bc9e10f0f0609bcccac136d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 9 Aug 2026 07:32:57 +0200 Subject: [PATCH 2/3] fix(security): do not let an enforced directive tighten one it absorbs Four review findings, one of which would have blocked working sites. CSP resolves a missing directive by walking a fallback chain, so a policy carrying `script-src` and no `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. A project declaring only `scriptSrc` would have found its `blob:` workers blocked by an enforced `script-src` that never mentioned them, while the reported `worker-src` said they were fine. The companion policy now carries the fallback closure of whatever it enforces, with the values the reported policy gives those directives, so declaring one directive cannot tighten another. `undefined` is now treated as absent when deciding what was declared, matching what the merge already does. Without it `scriptSrc: undefined` enforced a merged `script-src` the project never configured. The integration suite's `readCsp` preferred the enforced header, which now carries only the binding subset, so its nonce and policy-content assertions were reading the wrong header. The guide claimed the always-enforced pair "costs you nothing" and omitted that declaring `formAction` or `frameAncestors` binds them too. It now says what each blocks and states the contract as implemented. --- docs/api-reference/veryfront/security.md | 4 +- docs/guides/security-headers.md | 11 ++- .../http/response/security-handler.test.ts | 31 +++++++ .../http/response/security-handler.ts | 81 ++++++++++++++++--- .../server/production-server.test.ts | 11 ++- 5 files changed, 123 insertions(+), 15 deletions(-) diff --git a/docs/api-reference/veryfront/security.md b/docs/api-reference/veryfront/security.md index d2ee26793a..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#L382) | +| `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#L369) | +| `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 dbc383d912..f1e3b54d0c 100644 --- a/docs/guides/security-headers.md +++ b/docs/guides/security-headers.md @@ -43,9 +43,16 @@ Three directives are worth understanding: 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, in a second `Content-Security-Policy` header, for every project: `object-src 'none'` and `base-uri 'self'`. Both close injection routes that no ordinary site uses, so they cost you nothing and hold from the start. +Two directives are served enforced, in a second `Content-Security-Policy` header, for every project: -Anything you declare in `security.csp` is enforced too. 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. 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. +- `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. + +**Anything you declare 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. 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. diff --git a/src/security/http/response/security-handler.test.ts b/src/security/http/response/security-handler.test.ts index 988415ac5d..e4b6d6d9df 100644 --- a/src/security/http/response/security-handler.test.ts +++ b/src/security/http/response/security-handler.test.ts @@ -790,6 +790,37 @@ describe("security/http/response/security-handler", () => { assertStringIncludes(headers.get("Content-Security-Policy-Report-Only") ?? "", "script-src"); }); + 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") ?? ""; diff --git a/src/security/http/response/security-handler.ts b/src/security/http/response/security-handler.ts index 3d1b9e937a..bd60d0fff5 100644 --- a/src/security/http/response/security-handler.ts +++ b/src/security/http/response/security-handler.ts @@ -317,15 +317,74 @@ const ALWAYS_ENFORCED_DIRECTIVES: ReadonlySet = new Set([ "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. Declaring one is taken as meaning it -- a project that lists its - * image origins wants `img-src` to hold -- while the 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. + * 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. * * @returns the enforced policy, or "" when the full policy already binds */ @@ -338,10 +397,14 @@ export function buildEnforcedCSP( ): string { if (isDev || isCspEnforced(hasEnvOverride)) return ""; - const declared = new Set( - Object.keys(config?.csp ?? {}).map((key) => toCspDirectiveName(key)), - ); - const binding = new Set([...ALWAYS_ENFORCED_DIRECTIVES, ...declared]); + // `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), 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 { From 87ab6e9acce798bbf60c08b4281ad2356f24796b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 9 Aug 2026 07:45:44 +0200 Subject: [PATCH 3/3] docs(security): state the enforcement contract without the overclaims Two inaccuracies in the section I added. "For every project" was wrong: `VERYFRONT_CSP` replaces the policy wholesale and is served enforced on its own, with neither the reported floor nor the always-enforced pair beside it. It is the default that is described here, not an invariant. "Anything you declare" was wrong in the same direction: a directive written as `undefined` counts as unconfigured, matching what the merge does, so it keeps reporting rather than binding. The text now asks for a value rather than a mention. --- docs/guides/security-headers.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/guides/security-headers.md b/docs/guides/security-headers.md index f1e3b54d0c..23ec39c31d 100644 --- a/docs/guides/security-headers.md +++ b/docs/guides/security-headers.md @@ -43,14 +43,16 @@ Three directives are worth understanding: 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, in a second `Content-Security-Policy` header, for every project: +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. -**Anything you declare 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. 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. +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.