diff --git a/docs/api-reference/veryfront/security.md b/docs/api-reference/veryfront/security.md index 86825ef1f3..ee4f380c88 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#L266) | +| `applySecurityHeaders` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L324) | | `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) | @@ -65,8 +65,8 @@ applySecurityHeaders(response.headers, false, generateNonce(), null); | `createValidationError` | Create an input validation error. Convenience wrapper around INPUT_VALIDATION_FAILED.create(). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/errors.ts#L12) | | `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#L51) | -| `getSecurityHeader` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L253) | +| `generateNonce` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L69) | +| `getSecurityHeader` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L311) | | `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/src/security/http/response/security-handler.test.ts b/src/security/http/response/security-handler.test.ts index 64af39aa30..e69a8c4cd5 100644 --- a/src/security/http/response/security-handler.test.ts +++ b/src/security/http/response/security-handler.test.ts @@ -604,14 +604,36 @@ describe("security/http/response/security-handler", () => { }); describe("applySecurityHeaders", () => { + /** + * Read the policy whichever header carries it. The floor is served + * report-only until a project opts in, so tests asserting policy *content* + * 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"); + it("keeps the canonical policy-owned header list aligned with production output", () => { const headers = applyHeaders({ adapter: createMockAdapter({ VERYFRONT_COEP: "require-corp" }), }); + // Exactly one of the two CSP header names is emitted per response, so + // production output is a subset of the owned list rather than equal to it. + const emitted = [...headers.keys()].sort(); + const owned = [...SECURITY_POLICY_RESPONSE_HEADER_NAMES]; + for (const name of emitted) { + assert(owned.includes(name as typeof owned[number]), `${name} must be policy-owned`); + } assertEquals( - [...headers.keys()].sort(), - [...SECURITY_POLICY_RESPONSE_HEADER_NAMES].sort(), + emitted.filter((n) => n.startsWith("content-security-policy")).length, + 1, + "exactly one CSP header, never both", + ); + assertEquals( + owned.filter((n) => !n.startsWith("content-security-policy")).sort(), + emitted.filter((n) => !n.startsWith("content-security-policy")), + "every other owned header is emitted", ); }); @@ -646,7 +668,7 @@ describe("security/http/response/security-handler", () => { it("should set CSP frame-ancestors with veryfront origins when isVeryfrontDomain is true (SEC-007)", () => { const headers = applyHeaders({ isVeryfrontDomain: true }); - const csp = headers.get("Content-Security-Policy"); + const csp = getCsp(headers); assert(csp !== null, "CSP header should be present"); const frameAncestors = parseDirectiveSources(csp, "frame-ancestors"); assertEquals( @@ -662,7 +684,7 @@ describe("security/http/response/security-handler", () => { it("should set CSP frame-ancestors 'none' for non-veryfront domains (SEC-007)", () => { const headers = applyHeaders({ isVeryfrontDomain: false }); - const csp = headers.get("Content-Security-Policy"); + const csp = getCsp(headers); assert(csp !== null, "CSP header should be present"); assert( csp.includes("frame-ancestors 'none'"), @@ -707,12 +729,121 @@ describe("security/http/response/security-handler", () => { it("should set default CSP in production when no CSP config", () => { const headers = applyHeaders(); - assertEquals(headers.get("Content-Security-Policy"), buildCSP(false, "nonce", null)); + // Report-only: a project that configured nothing has not opted in. + assertEquals( + headers.get("Content-Security-Policy-Report-Only"), + buildCSP(false, "nonce", null), + ); + assertEquals(headers.has("Content-Security-Policy"), false); }); it("should not set CSP in dev mode when no CSP config", () => { const headers = applyHeaders({ isDev: true }); assertEquals(headers.has("Content-Security-Policy"), false); + 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. + 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); + }); + + 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("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); + 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. + const adapter = createMockAdapter({ VERYFRONT_CSP: "default-src 'self'" }); + const headers = applyHeaders({ adapter }); + assertEquals(headers.get("Content-Security-Policy"), "default-src 'self'"); + assertEquals(headers.has("Content-Security-Policy-Report-Only"), false); + }); + + it("ignores a project-supplied Content-Security-Policy in security.headers", () => { + // `security.headers` has no override path for CSP the way Referrer-Policy + // and X-Frame-Options do, so a value here would silently replace the + // merged platform floor rather than extend it. + const headers = applyHeaders({ + config: { + csp: { imgSrc: ["https://cdn.example.com"] }, + headers: { "Content-Security-Policy": "default-src *" }, + }, + }); + assertEquals( + headers.get("Content-Security-Policy"), + buildCSP(false, "nonce", { csp: { imgSrc: ["https://cdn.example.com"] } }), + ); + }); + + it("ignores a project-supplied report-only header, matching case-insensitively", () => { + // Header names are case-insensitive, and the report-only name is a live + // delivery mode now -- a project value here could flip which mode is + // served, not just what it contains. + const headers = applyHeaders({ + config: { headers: { "Content-Security-Policy-Report-Only": "default-src *" } }, + }); + assertEquals( + headers.get("Content-Security-Policy-Report-Only"), + buildCSP(false, "nonce", { headers: {} } as SecurityConfig), + ); + assertEquals(headers.has("Content-Security-Policy"), false); + }); + + it("still honors the override paths that are meant to exist", () => { + // Guard against over-correcting: skipping CSP must not disturb the + // headers `security.headers` is legitimately allowed to set. + const headers = applyHeaders({ + config: { + headers: { + "Content-Security-Policy": "default-src *", + "Referrer-Policy": "no-referrer", + "X-Custom": "kept", + }, + }, + }); + assertEquals(headers.get("Referrer-Policy"), "no-referrer"); + assertEquals(headers.get("X-Custom"), "kept"); + }); + + 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("should apply extra headers from config", () => { diff --git a/src/security/http/response/security-handler.ts b/src/security/http/response/security-handler.ts index 36f6d04709..00dd8ab56e 100644 --- a/src/security/http/response/security-handler.ts +++ b/src/security/http/response/security-handler.ts @@ -14,6 +14,10 @@ import type { SecurityConfig } from "./types.ts"; const logger = serverLogger.component("security-headers"); const warnedReservedCorsHeaderConfigs = new WeakSet(); +// Same suppression as above: applySecurityHeaders runs per response, so an +// unguarded warning would repeat for every request a misconfigured project +// serves. +const warnedReservedCspHeaderConfigs = new WeakSet(); /** * Response headers whose values and omissions are owned by the centralized @@ -23,6 +27,10 @@ const warnedReservedCorsHeaderConfigs = new WeakSet(); export const SECURITY_POLICY_RESPONSE_HEADER_NAMES = Object.freeze( [ "content-security-policy", + // Owned for the same reason as the enforced header: the floor may be + // served report-only (see `cspHeaderName`), and a project-provided value + // must not survive into a response the platform is deciding the policy for. + "content-security-policy-report-only", "cross-origin-embedder-policy", "cross-origin-opener-policy", "cross-origin-resource-policy", @@ -42,6 +50,16 @@ export function isSecurityPolicyResponseHeaderName(name: string): boolean { return SECURITY_POLICY_RESPONSE_HEADER_NAME_SET.has(name.toLowerCase()); } +/** The two names the computed policy may be delivered under. */ +const CSP_RESPONSE_HEADER_NAMES: ReadonlySet = new Set([ + "content-security-policy", + "content-security-policy-report-only", +]); + +function isCspResponseHeaderName(name: string): boolean { + return CSP_RESPONSE_HEADER_NAMES.has(name.toLowerCase()); +} + /** HSTS max-age default: 1 year in seconds */ const HSTS_MAX_AGE_SECONDS = 31_536_000; @@ -250,6 +268,46 @@ export function buildCSP( ); } +/** + * Whether the built policy is enforced or merely reported. + * + * 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. + * + * 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. + */ +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; +} + +/** 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) + ? "Content-Security-Policy" + : "Content-Security-Policy-Report-Only"; +} + export function getSecurityHeader( headerName: string, defaultValue: string, @@ -302,7 +360,10 @@ export function applySecurityHeaders( headers.set("X-XSS-Protection", getHeaderOverride("x-xss-protection") ?? "0"); const csp = buildCSP(isDev, nonce, config, adapter, isVeryfrontDomain); - if (csp) headers.set("Content-Security-Policy", csp); + if (csp) { + const hasEnvOverride = Boolean(adapter?.env?.get?.("VERYFRONT_CSP")?.trim()); + headers.set(cspHeaderName(config, adapter, hasEnvOverride), csp); + } if (!isDev) { const hstsMaxAge = config?.hsts?.maxAge ?? HSTS_MAX_AGE_SECONDS; @@ -335,12 +396,25 @@ export function applySecurityHeaders( const extraHeaders = config?.headers; if (extraHeaders) { let ignoredCorsPolicyHeader = false; + let ignoredCspHeader = false; for (const [key, value] of Object.entries(extraHeaders)) { if (value === undefined) continue; if (isCorsPolicyResponseHeaderName(key)) { ignoredCorsPolicyHeader = true; continue; } + // The policy is computed above and is not a project-settable header. + // Every other name here has a deliberate override path through + // `getHeaderOverride`; CSP has none, so a value arriving via + // `security.headers` would silently replace the merged platform floor -- + // and, now that the floor may be delivered report-only, could also flip + // which mode is served. Header names are case-insensitive, so match that + // way. `isSecurityPolicyResponseHeaderName` is deliberately not reused: + // it covers headers this loop is still allowed to override. + if (isCspResponseHeaderName(key)) { + ignoredCspHeader = true; + continue; + } headers.set(key, value); } if ( @@ -352,6 +426,15 @@ export function applySecurityHeaders( "Ignored reserved Access-Control-* entries in security.headers; configure security.cors instead", ); } + if ( + ignoredCspHeader && + !warnedReservedCspHeaderConfigs.has(extraHeaders) + ) { + warnedReservedCspHeaderConfigs.add(extraHeaders); + logger.warn( + "Ignored Content-Security-Policy entries in security.headers; configure security.csp instead", + ); + } } recordSecurityHeaders(); diff --git a/src/server/handlers/request/openapi-docs.handler.test.ts b/src/server/handlers/request/openapi-docs.handler.test.ts index 36e8982cb8..f27ec7fc77 100644 --- a/src/server/handlers/request/openapi-docs.handler.test.ts +++ b/src/server/handlers/request/openapi-docs.handler.test.ts @@ -32,7 +32,10 @@ describe("server/handlers/request/openapi-docs.handler", () => { const response = result.response!; const body = await response.text(); - const csp = response.headers.get("content-security-policy") ?? ""; + // 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 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 0165101d46..dd8882ec77 100644 --- a/src/server/handlers/request/rsc/index.test.ts +++ b/src/server/handlers/request/rsc/index.test.ts @@ -61,7 +61,10 @@ describe("server/handlers/request/rsc", () => { const response = result.response!; const html = await response.text(); - const csp = response.headers.get("content-security-policy") ?? ""; + // 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 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 6bb9fe4b06..db22547b23 100644 --- a/src/server/handlers/request/static.handler.test.ts +++ b/src/server/handlers/request/static.handler.test.ts @@ -215,7 +215,10 @@ describe("server/handlers/request/static.handler", () => { const response = result.response; const body = await response.text(); - const csp = response.headers.get("content-security-policy") ?? ""; + // 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 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 4f5da04cd7..5dc4689769 100644 --- a/tests/integration/server/production-server.test.ts +++ b/tests/integration/server/production-server.test.ts @@ -30,6 +30,18 @@ import { invalidateProjectMiddlewareCache } from "../../../src/server/runtime-ha import { registerTailwindExtension } from "../../../src/html/styles-builder/__tests__/css-processor-setup.ts"; import { deleteEnv, getHostEnv, setEnv } from "../../../src/platform/compat/process.ts"; +/** + * Read the served policy from whichever header carries it. The platform floor + * is served `-Report-Only` by default; a `security.csp` declaration, + * `VERYFRONT_CSP`, or `VERYFRONT_CSP_ENFORCE` selects enforced delivery + * instead. These fixtures declare none of those; the assertions below are about + * policy content and nonce alignment, not delivery mode. + */ +function readCsp(headers: Headers): string | null { + return headers.get("content-security-policy") ?? + headers.get("content-security-policy-report-only"); +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -222,7 +234,7 @@ describe( const html = await response.text(); assertEquals(response.status, 200, "Should serve built App Router HTML"); - const csp = response.headers.get("content-security-policy") ?? ""; + const csp = readCsp(response.headers) ?? ""; const nonceMatch = csp.match(/nonce-([^' ;]+)/); assertExists(nonceMatch, "CSP should include a nonce"); const nonce = nonceMatch[1]!; @@ -284,7 +296,7 @@ describe( const res = await fetch(`http://127.0.0.1:${server.port}/`); assertEquals(res.status, 200, "Should serve the page"); - const csp = res.headers.get("content-security-policy"); + const csp = readCsp(res.headers); assert(csp !== null, "CSP should be set by default in production"); assert( csp!.includes("default-src 'self'"), @@ -329,7 +341,7 @@ describe( "DENY", "Should prevent framing by default", ); - const csp = response.headers.get("content-security-policy"); + const csp = readCsp(response.headers); assert(csp !== null, "Default CSP should be set in production"); assert( csp!.includes("default-src 'self'"), @@ -417,7 +429,7 @@ describe( const html = await response.text(); assertEquals(response.status, 200, "Should serve built Pages Router HTML"); - const csp = response.headers.get("content-security-policy") ?? ""; + const csp = readCsp(response.headers) ?? ""; const nonceMatch = csp.match(/nonce-([^' ;]+)/); assertExists(nonceMatch, "CSP should include a nonce"); const nonce = nonceMatch[1]!;