From 25f6d21c208900f38247e42bed919b04b97c7afd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 6 Aug 2026 10:44:54 +0200 Subject: [PATCH 1/5] feat(security): merge project CSP into the platform floor instead of replacing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting `security.csp` replaced the entire policy rather than adding to it. A project that allowed one font origin got exactly that directive and nothing else -- and because CSP only restricts the resource types it names, a policy with no script-src and no default-src leaves script loading unrestricted. The single action a project is most likely to take against this config switched its protection off while looking like it tightened it, and dropped the `script-src esm.sh` the renderer needs, so the site stopped hydrating too. This is live: codersociety.com emits a Google Fonts stylesheet against `style-src-elem 'self' 'unsafe-inline'` and `font-src 'self' data:`, so the font is blocked today -- and there was no valid config that fixed it. Project config is now additive: effective[directive] = required ∪ (project === null ? {} : baseline ∪ project) The floor splits in two. Required sources (`'self'`, the nonce, esm.sh in script-src, the platform image origins) are what the renderer writes into every document, so no project config can drop them. Baseline sources ('unsafe-inline', data:, blob:) are conveniences a project may drop with `null` -- `styleSrc: null` yields `style-src 'self'`. That is how hardening past the floor is expressed without being able to brick the site. The `null` sentinel matches Helmet, so the semantics are already familiar. Also in this change: - Delete `cspUserHeader`. It was a pre-serialized duplicate of `securityConfig.csp` threaded through the handler context beside the object it came from, and `buildCSP` returned it before ever reaching the config branch. Merging on that branch alone would have shipped a no-op, so this is a prerequisite rather than cleanup. 51 of the 63 touched files are fixtures. Tenant isolation is unaffected: both fields came from the same request-scoped `deriveSecurityContext` result. - Drop `style-src-elem` from the floor. It duplicated `style-src` exactly, and because it takes precedence for its only live effect was to make a project's `styleSrc` addition silently fail to admit a stylesheet. - Add the script origins to `connect-src`, converging with the RSC policy in production-optimizer.ts and letting browsers fetch the source maps the CDN's own modules reference. - Reject unknown directive names at config load. Browsers ignore unrecognized directives, so `fontSource: [...]` would otherwise read as configured and protect nothing. - Remove `serializeCSPDirectives`, now unused and not re-exported. Enforcement is unchanged and deliberate: the floor applies in production whether or not a project configures anything, and `VERYFRONT_CSP` remains the ops-level full replacement. Only the floor is enforced; everything above it stays a project decision. Verified against all 15 configs under projects/: none sets `security.csp`, so no project relies on the replaced semantics. Docs: new guide at docs/guides/security-headers.md, whose default-policy block is copied from the builder's real output. --- docs/guides/index.md | 15 +- docs/guides/security-headers.md | 118 ++++++++ src/channels/control-plane.test.ts | 1 - src/channels/invoke.test.ts | 1 - src/config/schemas/config.schema.ts | 30 ++- .../middleware/http-error-boundary.test.ts | 1 - src/repositories/repositories.test.ts | 1 - src/routing/api/handler.test.ts | 7 - src/routing/registry/registry.test.ts | 1 - src/security/http/auth.test.ts | 9 - src/security/http/base-handler.ts | 1 - src/security/http/config.test.ts | 20 +- src/security/http/config.ts | 12 +- src/security/http/csp-directives.ts | 72 +++++ src/security/http/csrf/csrf-handler.test.ts | 1 - src/security/http/response/builder.ts | 2 - src/security/http/response/fluent-methods.ts | 2 - .../http/response/security-handler.test.ts | 254 +++++++++--------- .../http/response/security-handler.ts | 209 +++++++++----- src/security/http/response/static-helpers.ts | 3 - src/security/http/response/types.ts | 1 - src/server/handlers/dev/dashboard/api.test.ts | 1 - .../handlers/dev/dashboard/index.test.ts | 1 - .../dev/files/dev-file.handler.test.ts | 1 - .../handlers/dev/projects/index.test.ts | 1 - .../dev/projects/method-policy.test.ts | 1 - .../dev/styles-css-error-response.test.ts | 1 - .../handlers/dev/styles-css.handler.test.ts | 1 - .../handlers/preview/hmr.handler.test.ts | 1 - .../preview/markdown-preview.handler.test.ts | 2 - .../request/api/api-handler-wrapper.test.ts | 1 - .../request/api/app-router-handler.test.ts | 2 - .../request/api/app-router-resolver.test.ts | 1 - .../request/api/pages-api-handler.test.ts | 1 - .../request/api/project-discovery.test.ts | 1 - .../request/api/security-headers.test.ts | 1 - .../handlers/request/api/security-headers.ts | 2 - .../request/channel-dispatch-request.test.ts | 1 - .../request/channel-invoke.handler.test.ts | 1 - .../handlers/request/css.handler.test.ts | 1 - .../internal-agent-run.test-helpers.ts | 1 - .../request/lib-modules.handler.test.ts | 1 - .../module/data-endpoint-handler.test.ts | 1 - .../module/module-server-handler.test.ts | 1 - .../request/module/module.handler.test.ts | 1 - .../module/page-data-endpoint-handler.test.ts | 1 - .../module/page-module-handler.test.ts | 1 - .../request/openapi-docs.handler.test.ts | 1 - .../prod-hydration-module.handler.test.ts | 1 - .../request/route-visibility-policy.test.ts | 1 - src/server/handlers/request/rsc/index.test.ts | 1 - .../request/ssr/error-page-fallback.test.ts | 1 - .../request/ssr/not-found-fallback.test.ts | 1 - .../request/ssr/ssr-response-builder.test.ts | 1 - .../request/ssr/ssr.handler.test-helpers.ts | 1 - .../handlers/request/static.handler.test.ts | 1 - src/server/handlers/response/cors.test.ts | 1 - .../utils/dependency-pinning-source.test.ts | 1 - .../handler-context-builder.test.ts | 6 +- .../handler-context-builder.ts | 5 - .../handler-registry-factory.test.ts | 1 - src/server/runtime-handler/index.ts | 3 - .../project-middleware.test.ts | 1 - .../project-runtime-context.test.ts | 22 +- .../project-runtime-context.ts | 2 - .../services/rendering/ssr.service.test.ts | 1 - src/types/server.ts | 8 +- 67 files changed, 520 insertions(+), 330 deletions(-) create mode 100644 docs/guides/security-headers.md create mode 100644 src/security/http/csp-directives.ts diff --git a/docs/guides/index.md b/docs/guides/index.md index ba5176abcc..392aee9531 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -21,13 +21,14 @@ details, see [API reference](../api-reference/index.md). ## Build routes -| Goal | Guide | -| ------------------------------------------- | ------------------------------------------- | -| Add pages, layouts, and dynamic routes | [Pages and routing](./pages-and-routing.md) | -| Load data for pages | [Data fetching](./data-fetching.md) | -| Add HTTP endpoints | [API routes](./api-routes.md) | -| Add CORS, auth checks, logging, or timeouts | [Middleware](./middleware.md) | -| Set page metadata and social previews | [Head and SEO](./head-and-seo.md) | +| Goal | Guide | +| ----------------------------------------------- | ------------------------------------------------- | +| Add pages, layouts, and dynamic routes | [Pages and routing](./pages-and-routing.md) | +| Load data for pages | [Data fetching](./data-fetching.md) | +| Add HTTP endpoints | [API routes](./api-routes.md) | +| Add CORS, auth checks, logging, or timeouts | [Middleware](./middleware.md) | +| Allow Google Fonts or other third-party origins | [Security headers and CSP](./security-headers.md) | +| Set page metadata and social previews | [Head and SEO](./head-and-seo.md) | ## Add AI behavior diff --git a/docs/guides/security-headers.md b/docs/guides/security-headers.md new file mode 100644 index 0000000000..23456e1487 --- /dev/null +++ b/docs/guides/security-headers.md @@ -0,0 +1,118 @@ +--- +title: "Security headers and CSP" +description: "The Content-Security-Policy Veryfront applies by default, and how to allow Google Fonts, analytics, and other third-party origins." +order: 11 +--- + +Every hosted Veryfront project is served with a Content-Security-Policy and a set of hardening headers. You do not switch them on — they apply in production whether or not you configure anything. What you do configure is the extra origins your own site needs. + +## The default policy + +In production, Veryfront serves this policy: + +```http +default-src 'self'; +script-src 'self' 'nonce-' https://esm.sh; +style-src 'self' 'unsafe-inline'; +style-src-attr 'unsafe-inline'; +img-src 'self' https://images.veryfront.com https://cdn.veryfront.com data:; +font-src 'self' data:; +connect-src 'self' https://esm.sh; +media-src 'self' blob:; +worker-src 'self' blob:; +object-src 'none'; +frame-src 'self'; +frame-ancestors 'none'; +base-uri 'self'; +form-action 'self' +``` + +Alongside it: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Strict-Transport-Security`, and `Cross-Origin-Opener-Policy` / `Cross-Origin-Resource-Policy` set to `same-origin`. + +Development serves no CSP at all, so HMR and dev tooling are never blocked and a local allowance can never widen your production policy. + +Two directives are worth understanding: + +- **`script-src` includes `https://esm.sh`** because the renderer writes React imports from that CDN into every document. A fresh nonce is generated per response for the framework's own inline bootstrap. +- **`frame-ancestors`** is `'none'` on your own domain. On `*.veryfront.com` addresses it instead allows the Studio origins, so the Studio preview iframe works. + +## Adding an origin + +Set `security.csp` in `veryfront.config.ts`. Values are **added to** the defaults — you never restate them: + +```ts +export default { + security: { + csp: { + styleSrc: ["https://fonts.googleapis.com"], + fontSrc: ["https://fonts.gstatic.com"], + }, + }, +}; +``` + +That is the complete Google Fonts setup: `fonts.googleapis.com` serves the stylesheet, `fonts.gstatic.com` serves the font files, and both directives keep everything they already had. + +Directive names may be camelCase (`fontSrc`) or the CSP spelling (`font-src`). Both work; camelCase matches the rest of your config. You do not need to repeat `'self'` — it is already there. + +A few more examples: + +```ts +security: { + csp: { + // An analytics endpoint your client code posts to + connectSrc: ["https://analytics.example.com"], + // Embedding YouTube + frameSrc: ["https://www.youtube.com"], + // Images from your own CDN + imgSrc: ["https://cdn.example.com"], + }, +} +``` + +Misspelling a directive fails your build rather than silently doing nothing — browsers ignore unrecognized directive names, so `fontSource: [...]` would otherwise look configured and protect nothing. + +## What you cannot remove + +Some sources are structural: the renderer writes those URLs into the documents it serves, so a project that dropped them would break only its own site. `'self'`, the nonce, `https://esm.sh` in `script-src`, and the platform image origins are always present, whatever your config says. + +Everything else is a convenience you may drop. + +## Tightening the policy + +To remove the platform's optional sources for one directive, set it to `null`: + +```ts +security: { + csp: { + // Serve no inline styles. Keeps 'self'; drops 'unsafe-inline'. + styleSrc: null, + }, +} +``` + +`null` removes the optional half of a directive and keeps the required half. It cannot lock you out of your own site. + +Before doing this, check what your components actually need. `'unsafe-inline'` is in the default `style-src` because many React component libraries — including Veryfront's own — create styles at runtime. Removing it is safe only if you are certain yours do not. + +## Replacing the policy entirely + +Setting `VERYFRONT_CSP` in the environment replaces the whole policy, including the sources the renderer needs: + +```bash +VERYFRONT_CSP="default-src 'self'; script-src 'self' 'nonce-{NONCE}'" +``` + +`{NONCE}` is substituted with the per-response nonce. Omitting `https://esm.sh` from `script-src` will stop your pages hydrating, so this is an operations-level escape hatch for policies you intend to own completely — not the way to add an origin. Use `security.csp` for that. + +## Checking your policy + +Load your site and open the browser console. CSP violations name the directive that blocked the request, which maps directly onto the config key: a `style-src-elem` violation is fixed with `styleSrc`, a `font-src` violation with `fontSrc`. + +Preview deployments serve the same policy as production, so a CSP problem shows up on your preview URL before it reaches your live site. + +## Related + +- [Configuration](./configuration.md) — the full `veryfront.config.ts` reference +- [Middleware](./middleware.md) — CORS, rate limiting, and auth checks +- [Deploying](./deploying.md) — preview and production environments diff --git a/src/channels/control-plane.test.ts b/src/channels/control-plane.test.ts index f26c973829..5a1e85cef3 100644 --- a/src/channels/control-plane.test.ts +++ b/src/channels/control-plane.test.ts @@ -93,7 +93,6 @@ function createHandlerContext(): HandlerContext { fs: {}, }, securityConfig: null, - cspUserHeader: null, projectSlug: "demo-project", projectId: "proj-1", isLocalProject: false, diff --git a/src/channels/invoke.test.ts b/src/channels/invoke.test.ts index bfa68386eb..0e4a73d810 100644 --- a/src/channels/invoke.test.ts +++ b/src/channels/invoke.test.ts @@ -109,7 +109,6 @@ function createHandlerContext(): HandlerContext { fs: {}, }, securityConfig: null, - cspUserHeader: null, projectSlug: "demo-project", projectId: "proj-1", isLocalProject: false, diff --git a/src/config/schemas/config.schema.ts b/src/config/schemas/config.schema.ts index 8e3d6f49af..0939b78e1d 100644 --- a/src/config/schemas/config.schema.ts +++ b/src/config/schemas/config.schema.ts @@ -9,6 +9,10 @@ import { MAX_SOURCE_INTEGRATION_POLICY_TOOL_IDS, } from "#veryfront/integrations/limits.ts"; import { ALL_INTEGRATION_NAMES } from "#veryfront/integrations/schema.ts"; +import { + EXAMPLE_CSP_DIRECTIVES, + isCspDirectiveName, +} from "#veryfront/security/http/csp-directives.ts"; import type { SourceIntegrationPolicyConfig, SourceIntegrationRestriction, @@ -514,7 +518,31 @@ export const getVeryfrontConfigSchema = defineSchema((v) => "Configure either basic or bearer authentication, not both", ) .optional(), - csp: v.record(v.string(), v.array(v.string())).optional(), + /** + * Extra CSP sources, merged into the platform's baseline policy. + * + * Additive: `{ fontSrc: ["https://fonts.gstatic.com"] }` keeps every + * default and adds that origin. `null` drops the platform's optional + * sources for one directive (e.g. `styleSrc: null` removes + * `'unsafe-inline'`) while keeping the ones the renderer requires. + */ + csp: v + .record(v.string(), v.union([v.array(v.string()), v.null()])) + .superRefine((csp, ctx) => { + for (const key of Object.keys(csp)) { + if (isCspDirectiveName(key)) continue; + // Browsers ignore unknown directives silently, so a typo would + // otherwise read as configured and protect nothing. + ctx.addIssue({ + message: `Unknown Content-Security-Policy directive "${key}". ` + + `Use a directive name such as ${ + EXAMPLE_CSP_DIRECTIVES.join(", ") + } (camelCase or kebab-case).`, + path: ["csp", key], + }); + } + }) + .optional(), remoteHosts: v .array(v.string().max(MAX_REMOTE_HOST_URL_LENGTH).url()) .max(MAX_REMOTE_HOST_COUNT) diff --git a/src/errors/middleware/http-error-boundary.test.ts b/src/errors/middleware/http-error-boundary.test.ts index 875d2d3180..e05d068c91 100644 --- a/src/errors/middleware/http-error-boundary.test.ts +++ b/src/errors/middleware/http-error-boundary.test.ts @@ -22,7 +22,6 @@ function createMockContext(isLocalProject = false): HandlerContext { projectDir: "/test/project", adapter: {} as HandlerContext["adapter"], securityConfig: null, - cspUserHeader: null, isLocalProject, }; } diff --git a/src/repositories/repositories.test.ts b/src/repositories/repositories.test.ts index f5d2d5f9b4..50352e8fa5 100644 --- a/src/repositories/repositories.test.ts +++ b/src/repositories/repositories.test.ts @@ -296,7 +296,6 @@ describe("extractRepositoryContext", () => { projectDir: "/path/to/project", adapter: {} as HandlerContext["adapter"], securityConfig: null, - cspUserHeader: null, }; } diff --git a/src/routing/api/handler.test.ts b/src/routing/api/handler.test.ts index c5b1f40f2e..a1c89c9381 100644 --- a/src/routing/api/handler.test.ts +++ b/src/routing/api/handler.test.ts @@ -148,7 +148,6 @@ describe("APIRouteHandler", () => { projectDir: "/test/project", adapter, securityConfig: null, - cspUserHeader: null, isLocalProject: false, prepareHostedConfigContext: () => Promise.reject(new Error("hosted config must not be evaluated")), @@ -211,7 +210,6 @@ describe("APIRouteHandler", () => { projectDir: "/test/project", adapter, securityConfig: null, - cspUserHeader: null, isLocalProject: false, } satisfies HandlerContext; @@ -287,7 +285,6 @@ describe("APIRouteHandler", () => { projectDir: "/test/project", adapter, securityConfig: null, - cspUserHeader: null, isLocalProject: true, }, ); @@ -329,7 +326,6 @@ describe("APIRouteHandler", () => { projectDir: "/test/project", adapter, securityConfig: null, - cspUserHeader: null, isLocalProject: false, allowHostProjectCodeExecution: true, }, @@ -378,7 +374,6 @@ describe("APIRouteHandler", () => { projectDir: "/test/project", adapter, securityConfig: null, - cspUserHeader: null, isLocalProject: true, }, ), @@ -555,7 +550,6 @@ describe("APIRouteHandler", () => { projectDir: "/test/project", adapter, securityConfig: null, - cspUserHeader: null, isLocalProject: true, } satisfies HandlerContext; const responsePromise = handler.handle( @@ -807,7 +801,6 @@ describe("APIRouteHandler", () => { projectDir: "/test/project", adapter, securityConfig: null, - cspUserHeader: null, isLocalProject: true, }, }; diff --git a/src/routing/registry/registry.test.ts b/src/routing/registry/registry.test.ts index 51903ea18c..077d411ddb 100644 --- a/src/routing/registry/registry.test.ts +++ b/src/routing/registry/registry.test.ts @@ -23,7 +23,6 @@ function makeCtx(): HandlerContext { projectDir: "/tmp/test", adapter: {} as HandlerContext["adapter"], securityConfig: null, - cspUserHeader: null, }; } diff --git a/src/security/http/auth.test.ts b/src/security/http/auth.test.ts index 995b0a3ca7..48682e4967 100644 --- a/src/security/http/auth.test.ts +++ b/src/security/http/auth.test.ts @@ -19,7 +19,6 @@ describe("AuthHandler realm sanitization", () => { return { projectDir: "/tmp/auth-test", securityConfig: { auth: { basic } } as unknown as SecurityConfig, - cspUserHeader: null, adapter: { env: { get: () => "" }, } as unknown as HandlerContext["adapter"], @@ -155,7 +154,6 @@ describe("AuthHandler realm sanitization", () => { auth: { bearer: { token: "expected-token" } }, cors: { origin: "https://client.example" }, } as SecurityConfig, - cspUserHeader: null, adapter: { env: { get: () => "" }, } as unknown as HandlerContext["adapter"], @@ -190,7 +188,6 @@ describe("AuthHandler realm sanitization", () => { const ctx: HandlerContext = { projectDir: "/tmp/auth-test", securityConfig: null, - cspUserHeader: null, adapter: { env: { get: (name: string) => credentials[name] }, } as unknown as HandlerContext["adapter"], @@ -235,7 +232,6 @@ describe("AuthHandler realm sanitization", () => { const ctx: HandlerContext = { projectDir: "/tmp/auth-test", securityConfig: null, - cspUserHeader: null, adapter: { env: { get: (name: string) => credentials[name] }, } as unknown as HandlerContext["adapter"], @@ -308,7 +304,6 @@ describe("AuthHandler realm sanitization", () => { const ctx: HandlerContext = { projectDir: "/tmp/auth-test", securityConfig: null, - cspUserHeader: null, adapter: { env: { get: (name: string) => name === "VERYFRONT_BEARER_TOKEN" ? "required" : undefined, @@ -345,7 +340,6 @@ describe("AuthHandler realm sanitization", () => { const ctx: HandlerContext = { projectDir: "/tmp/auth-test", securityConfig, - cspUserHeader: null, adapter: { env: { get: () => undefined }, } as unknown as HandlerContext["adapter"], @@ -375,7 +369,6 @@ describe("AuthHandler realm sanitization", () => { const ctx: HandlerContext = { projectDir: "/tmp/auth-test", securityConfig, - cspUserHeader: null, adapter: { env: { get: () => undefined }, } as unknown as HandlerContext["adapter"], @@ -402,7 +395,6 @@ describe("AuthHandler realm sanitization", () => { const ctx: HandlerContext = { projectDir: "/tmp/auth-test", securityConfig: { auth } as unknown as SecurityConfig, - cspUserHeader: null, adapter: { env: { get: () => undefined }, } as unknown as HandlerContext["adapter"], @@ -441,7 +433,6 @@ describe("AuthHandler realm sanitization", () => { const ctx: HandlerContext = { projectDir: "/tmp/auth-test", securityConfig: { auth } as unknown as SecurityConfig, - cspUserHeader: null, adapter: { env: { get: () => undefined }, } as unknown as HandlerContext["adapter"], diff --git a/src/security/http/base-handler.ts b/src/security/http/base-handler.ts index 7bbbf58556..ed92f3ebda 100644 --- a/src/security/http/base-handler.ts +++ b/src/security/http/base-handler.ts @@ -85,7 +85,6 @@ export abstract class BaseHandler implements Handler { return new ResponseBuilder({ securityConfig: ctx.securityConfig ?? undefined, isDev: isExplicitlyLocalProject(ctx), - cspUserHeader: ctx.cspUserHeader, adapter: ctx.adapter, nonce, isVeryfrontDomain: ctx.parsedDomain?.allowIframeEmbed ?? false, diff --git a/src/security/http/config.test.ts b/src/security/http/config.test.ts index d4c11da07e..71eb6b89cd 100644 --- a/src/security/http/config.test.ts +++ b/src/security/http/config.test.ts @@ -1,7 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { DenoAdapter } from "#veryfront/platform/adapters/runtime/deno/adapter.ts"; -import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { clearConfigCache, type VeryfrontConfig } from "#veryfront/config"; import { @@ -135,14 +135,12 @@ describe("security/http/config", () => { await loader.ensureLoaded(); - assertEquals( - loader.getCspUserHeader(), - "default-src 'self'; script-src 'self' 'nonce-{NONCE}'", - ); - assertEquals( - loader.buildCsp(false, "abc123"), - "default-src 'self'; script-src 'self' 'nonce-abc123'", + const csp = loader.buildCsp(false, "abc123"); + assert( + csp.includes("'nonce-abc123'"), + "the {NONCE} placeholder is substituted in project sources", ); + assert(csp.includes("object-src 'none'"), "project config merges into the floor"); }); it("prefers configured headers over env headers and falls back to defaults", async () => { @@ -181,11 +179,11 @@ describe("security/http/config", () => { await loader.ensureLoaded(); assertEquals(loader.getSecurityConfig()?.cors, true); - assertEquals(loader.getCspUserHeader(), "default-src 'self'"); + assertEquals(loader.getSecurityConfig()?.csp, { "default-src": ["'self'"] }); assertEquals("reset" in loader, false); assertEquals(loader.getSecurityConfig()?.cors, true); - assertEquals(loader.getCspUserHeader(), "default-src 'self'"); + assertEquals(loader.getSecurityConfig()?.csp, { "default-src": ["'self'"] }); }); it("defaults CSRF protection on in production when not explicitly configured", async () => { @@ -333,7 +331,7 @@ describe("security/http/config", () => { (second.securityConfig.cors as { origin?: unknown }).origin === derivedCors.origin, false, ); - assertEquals(first.cspUserHeader, "default-src 'none'"); + assertEquals(first.securityConfig.csp, { "default-src": ["'none'"] }); sourceCors.methods?.push("POST"); assertEquals(derivedCors.methods, ["GET"]); diff --git a/src/security/http/config.ts b/src/security/http/config.ts index 9c8f4a0509..7dff94817e 100644 --- a/src/security/http/config.ts +++ b/src/security/http/config.ts @@ -3,14 +3,13 @@ import type { SecurityConfig } from "#veryfront/types"; import type { VeryfrontConfig } from "#veryfront/config"; import { getConfig, validateVeryfrontConfig } from "#veryfront/config"; import { serverLogger } from "#veryfront/utils"; -import { buildCSP, generateNonce, serializeCSPDirectives } from "./response/security-handler.ts"; +import { buildCSP, generateNonce } from "./response/security-handler.ts"; import { isProduction } from "#veryfront/platform/environment.ts"; const logger = serverLogger.component("security-config-loader"); export interface DerivedSecurityContext { securityConfig: SecurityConfig; - cspUserHeader: string | null; } export interface DeriveSecurityContextOptions { @@ -267,13 +266,11 @@ export function deriveSecurityContext( const securityConfig = Object.freeze(normalized); return Object.freeze({ securityConfig, - cspUserHeader: serializeCSPDirectives(securityConfig.csp), }); } export class SecurityConfigLoader { private securityConfig: SecurityConfig | null = null; - private cspUserHeader: string | null = null; private isLoaded = false; private loadPromise: Promise | null = null; @@ -324,7 +321,6 @@ export class SecurityConfigLoader { } this.securityConfig = security; - this.cspUserHeader = derived.cspUserHeader; this.isLoaded = true; } @@ -332,16 +328,12 @@ export class SecurityConfigLoader { return this.securityConfig; } - getCspUserHeader(): string | null { - return this.cspUserHeader; - } - getCorsConfig(): SecurityConfig["cors"] { return this.securityConfig?.cors; } buildCsp(isDev: boolean, nonce: string = generateNonce()): string { - return buildCSP(isDev, nonce, this.cspUserHeader, this.securityConfig, this.adapter); + return buildCSP(isDev, nonce, this.securityConfig, this.adapter); } getSecurityHeader(headerName: string, defaultValue: string): string { diff --git a/src/security/http/csp-directives.ts b/src/security/http/csp-directives.ts new file mode 100644 index 0000000000..3618c2f454 --- /dev/null +++ b/src/security/http/csp-directives.ts @@ -0,0 +1,72 @@ +/** + * Canonical Content-Security-Policy directive names. + * + * Kept as a leaf module so both the policy builder and the config validator + * read the same list. A project that misspells a directive is told at config + * load rather than discovering it as a silently missing protection in + * production — an unrecognized directive name is ignored by browsers, so + * without this check `fontSource: [...]` would look configured and do nothing. + * + * @module security/http/csp-directives + */ + +/** Every directive a project may name in `security.csp`. */ +export const CSP_DIRECTIVE_NAMES: readonly string[] = Object.freeze([ + "base-uri", + "block-all-mixed-content", + "child-src", + "connect-src", + "default-src", + "fenced-frame-src", + "font-src", + "form-action", + "frame-ancestors", + "frame-src", + "img-src", + "manifest-src", + "media-src", + "object-src", + "prefetch-src", + "report-to", + "report-uri", + "require-trusted-types-for", + "sandbox", + "script-src", + "script-src-attr", + "script-src-elem", + "style-src", + "style-src-attr", + "style-src-elem", + "trusted-types", + "upgrade-insecure-requests", + "worker-src", +]); + +const CSP_DIRECTIVE_NAME_SET: ReadonlySet = new Set(CSP_DIRECTIVE_NAMES); + +/** + * A short, representative sample for error messages. Listing all 28 directives + * buries the fix; these are the ones projects actually reach for. + */ +export const EXAMPLE_CSP_DIRECTIVES: readonly string[] = Object.freeze([ + "styleSrc", + "fontSrc", + "imgSrc", + "connectSrc", + "frameSrc", +]); + +/** + * Normalize a configured key to its directive name. + * + * `fontSrc` and `font-src` address the same directive; projects may write + * either, and camelCase matches the surrounding config style. + */ +export function toCspDirectiveName(key: string): string { + return key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); +} + +/** True when a configured key names a real CSP directive. */ +export function isCspDirectiveName(key: string): boolean { + return CSP_DIRECTIVE_NAME_SET.has(toCspDirectiveName(key)); +} diff --git a/src/security/http/csrf/csrf-handler.test.ts b/src/security/http/csrf/csrf-handler.test.ts index cf37d1fd7c..20c372a37a 100644 --- a/src/security/http/csrf/csrf-handler.test.ts +++ b/src/security/http/csrf/csrf-handler.test.ts @@ -10,7 +10,6 @@ function createCtx(csrf?: boolean | Record): HandlerContext { projectDir: "/tmp/test", adapter: { env: { get: () => undefined } } as unknown as HandlerContext["adapter"], securityConfig: csrf !== undefined ? { csrf } : null, - cspUserHeader: null, }; } diff --git a/src/security/http/response/builder.ts b/src/security/http/response/builder.ts index 9ccb487654..c992a4112f 100644 --- a/src/security/http/response/builder.ts +++ b/src/security/http/response/builder.ts @@ -12,7 +12,6 @@ export class ResponseBuilder implements FluentMethodsContext, ResponseMethodsCon securityConfig: SecurityConfig | null; isDev: boolean; nonce: string; - cspUserHeader: string | null; adapter: import("#veryfront/platform/adapters/base.ts").RuntimeAdapter | undefined; isVeryfrontDomain: boolean; @@ -20,7 +19,6 @@ export class ResponseBuilder implements FluentMethodsContext, ResponseMethodsCon this.securityConfig = config?.securityConfig ?? null; this.isDev = config?.isDev ?? false; this.nonce = config?.nonce ?? generateNonce(); - this.cspUserHeader = config?.cspUserHeader ?? null; this.adapter = config?.adapter; this.isVeryfrontDomain = config?.isVeryfrontDomain ?? false; } diff --git a/src/security/http/response/fluent-methods.ts b/src/security/http/response/fluent-methods.ts index dacc0b28f4..50edce80d4 100644 --- a/src/security/http/response/fluent-methods.ts +++ b/src/security/http/response/fluent-methods.ts @@ -14,7 +14,6 @@ export interface FluentMethodsContext { securityConfig: SecurityConfig | null; isDev: boolean; nonce: string; - cspUserHeader: string | null; adapter: import("#veryfront/platform/adapters/base.ts").RuntimeAdapter | undefined; isVeryfrontDomain: boolean; } @@ -56,7 +55,6 @@ export function withSecurity( this.headers, this.isDev, this.nonce, - this.cspUserHeader, config ?? this.securityConfig, this.adapter, this.isVeryfrontDomain, diff --git a/src/security/http/response/security-handler.test.ts b/src/security/http/response/security-handler.test.ts index 5926e6e83d..a14ca372ba 100644 --- a/src/security/http/response/security-handler.test.ts +++ b/src/security/http/response/security-handler.test.ts @@ -13,6 +13,7 @@ import type { SecurityConfig } from "./types.ts"; import { PLATFORM_ASSET_ORIGINS, PLATFORM_IMAGE_ORIGINS, + PLATFORM_SCRIPT_ORIGINS, } from "#veryfront/security/http/platform-asset-origins.ts"; import { ESM_CDN_BASE } from "#veryfront/utils/constants/cdn.ts"; @@ -58,14 +59,12 @@ function applyHeaders( { isDev = false, nonce = "nonce", - cspUserHeader = null, config = null, adapter, isVeryfrontDomain, }: { isDev?: boolean; nonce?: string; - cspUserHeader?: string | null; config?: SecurityConfig | null; adapter?: RuntimeAdapter; isVeryfrontDomain?: boolean; @@ -76,7 +75,6 @@ function applyHeaders( headers, isDev, nonce, - cspUserHeader, config, adapter, isVeryfrontDomain, @@ -101,7 +99,7 @@ describe("security/http/response/security-handler", () => { describe("buildCSP", () => { it("should return default CSP in production when no CSP is configured", () => { - const result = buildCSP(false, "test-nonce", null); + const result = buildCSP(false, "test-nonce"); assert(result.includes("default-src 'self'"), "should have default-src"); assert(result.includes("'nonce-test-nonce'"), "should include nonce in script-src"); assert(result.includes("object-src 'none'"), "should block objects"); @@ -109,8 +107,16 @@ describe("security/http/response/security-handler", () => { assert(result.includes("base-uri 'self'"), "should restrict base-uri"); }); + it("applies the floor to a project that configures nothing", () => { + // Enforcement is not opt-in: hosting means a project that never touches + // security config still gets a baseline. + const result = buildCSP(false, "n", null); + assert(result.includes("script-src"), "floor applies without any config"); + assert(result.includes("object-src 'none'")); + }); + it("should return empty string in dev mode when no CSP is configured", () => { - const result = buildCSP(true, "test-nonce", null); + const result = buildCSP(true, "test-nonce"); assertEquals(result, ""); }); @@ -118,129 +124,128 @@ describe("security/http/response/security-handler", () => { const adapter = createMockAdapter({ VERYFRONT_CSP: "default-src 'self' 'nonce-{NONCE}'", }); - const result = buildCSP(false, "abc123", null, null, adapter); + const result = buildCSP(false, "abc123", null, adapter); assertEquals(result, "default-src 'self' 'nonce-abc123'"); }); - it("should use cspUserHeader when set", () => { - const result = buildCSP(false, "xyz", "script-src 'nonce-{NONCE}'"); - assertEquals(result, "script-src 'nonce-xyz'"); - }); - - it("should build CSP from config csp object", () => { + it("merges project sources into the floor instead of replacing it", () => { + // The Google Fonts case: adding a font origin must not cost the project + // its script policy, which is what replace-semantics used to do. const config: SecurityConfig = { csp: { - "default-src": "'self'", - "script-src": "'nonce-{NONCE}'", + styleSrc: ["https://fonts.googleapis.com"], + fontSrc: ["https://fonts.gstatic.com"], }, }; - const result = buildCSP(false, "n1", null, config); - assert(result.includes("default-src 'self'")); - assert(result.includes("script-src 'nonce-n1'")); + const result = buildCSP(false, "n1", config); + + assertEquals(parseDirectiveRemoteHosts(result, "font-src"), ["fonts.gstatic.com"]); + assert(parseDirectiveSources(result, "font-src").includes("'self'"), "floor kept"); + assert( + parseDirectiveRemoteHosts(result, "style-src").includes("fonts.googleapis.com"), + "style-src carries the stylesheet origin", + ); + assert(result.includes("'nonce-n1'"), "script-src floor survives a font addition"); + assert(result.includes("object-src 'none'"), "unrelated floor directives survive"); }); - it("should handle camelCase CSP directive keys", () => { - const config: SecurityConfig = { - csp: { - defaultSrc: "'self'", - scriptSrc: "'nonce-{NONCE}'", - }, - }; - const result = buildCSP(false, "n2", null, config); - assert(result.includes("default-src 'self'")); - assert(result.includes("script-src 'nonce-n2'")); + it("admits a stylesheet without a competing style-src-elem", () => { + // style-src-elem duplicated style-src exactly and took precedence for + // , so a project's styleSrc addition silently failed to load it. + const result = buildCSP(false, "n", { + csp: { styleSrc: ["https://fonts.googleapis.com"] }, + }); + assert(!result.includes("style-src-elem"), "no shadowing directive is emitted"); }); - it("should handle array CSP directive values", () => { - const config: SecurityConfig = { - csp: { - "default-src": ["'self'", "https://cdn.example.com"], - }, - }; - const result = buildCSP(false, "n3", null, config); - const defaultSources = parseDirectiveSources(result, "default-src"); - const defaultHosts = parseDirectiveRemoteHosts(result, "default-src"); - assert(defaultSources.includes("'self'")); - assertEquals(defaultHosts, ["cdn.example.com"]); + it("should handle camelCase and kebab-case directive keys alike", () => { + const camel = buildCSP(false, "n2", { csp: { fontSrc: ["https://a.example"] } }); + const kebab = buildCSP(false, "n2", { csp: { "font-src": ["https://a.example"] } }); + assertEquals(camel, kebab); }); it("should skip undefined CSP directive values", () => { const config: SecurityConfig = { - csp: { - "default-src": "'self'", - "script-src": undefined, - }, + csp: { imgSrc: ["https://cdn.example.com"], scriptSrc: undefined }, }; - const result = buildCSP(false, "n4", null, config); - assertEquals(result, "default-src 'self'"); - }); - - it("should prioritize env CSP over cspUserHeader", () => { - const adapter = createMockAdapter({ VERYFRONT_CSP: "env-csp" }); - const result = buildCSP(false, "n5", "user-csp", null, adapter); - assertEquals(result, "env-csp"); + const result = buildCSP(false, "n4", config); + assert( + parseDirectiveRemoteHosts(result, "img-src").includes("cdn.example.com"), + "the configured origin is admitted", + ); + assert(result.includes("'nonce-n4'"), "an undefined value leaves the floor intact"); }); - it("should prioritize env CSP over config and default", () => { - const adapter = createMockAdapter({ VERYFRONT_CSP: "env-only" }); - const config: SecurityConfig = { csp: { "default-src": "'none'" } }; - const result = buildCSP(false, "n", "user-header", config, adapter); - assertEquals(result, "env-only", "env CSP has highest priority"); + it("collapses duplicate sources a project repeats from the floor", () => { + const result = buildCSP(false, "n", { csp: { fontSrc: ["'self'", "'self'"] } }); + assertEquals( + parseDirectiveSources(result, "font-src").filter((s) => s === "'self'").length, + 1, + ); }); - it("should prioritize cspUserHeader over config and default", () => { - const config: SecurityConfig = { csp: { "default-src": "'none'" } }; - const result = buildCSP(false, "n", "user-header", config); - assertEquals(result, "user-header", "user header takes priority over config"); + it("null drops the baseline sources but keeps the required ones", () => { + // This is how a project hardens past the floor. It must not be able to + // harden its way into a broken site. + const result = buildCSP(false, "n", { csp: { styleSrc: null } }); + const sources = parseDirectiveSources(result, "style-src"); + assertEquals(sources, ["'self'"], "'unsafe-inline' dropped, 'self' kept"); + assert(result.includes("'nonce-n'"), "script-src is untouched by a style-src opt-out"); + }); + + it("no project config can remove a required origin", () => { + // Required sources are what the renderer emits; a project that dropped + // them would only break its own site. + const attempts: (string[] | null)[] = [null, [], ["'none'"]]; + for (const attempt of attempts) { + const result = buildCSP(false, "n", { csp: { scriptSrc: attempt } }); + const sources = parseDirectiveSources(result, "script-src"); + assert(sources.includes("'self'"), `'self' survives ${JSON.stringify(attempt)}`); + assert(sources.includes("'nonce-n'"), `nonce survives ${JSON.stringify(attempt)}`); + assert( + parseDirectiveRemoteHosts(result, "script-src").includes("esm.sh"), + `the ESM CDN survives ${JSON.stringify(attempt)}`, + ); + } }); - it("should use config CSP over default", () => { - const config: SecurityConfig = { csp: { "default-src": "'none'" } }; - const result = buildCSP(false, "n", null, config); - assertEquals(result, "default-src 'none'", "config takes priority over default"); - assert(!result.includes("object-src"), "default directives should not leak into config CSP"); + it("a project source supersedes a floor of 'none'", () => { + const result = buildCSP(false, "n", { csp: { objectSrc: ["https://plugin.example"] } }); + const sources = parseDirectiveSources(result, "object-src"); + assert(!sources.includes("'none'"), "'none' is only meaningful alone"); + assertEquals(parseDirectiveRemoteHosts(result, "object-src"), ["plugin.example"]); }); - it("should fall through to default when config csp has only undefined values", () => { - const config: SecurityConfig = { - csp: { "default-src": undefined, "script-src": undefined }, - }; - const result = buildCSP(false, "n", null, config); - assert(result.includes("default-src 'self'"), "should fall through to default CSP"); + it("should prioritize env CSP over project config", () => { + const adapter = createMockAdapter({ VERYFRONT_CSP: "env-only" }); + const config: SecurityConfig = { csp: { fontSrc: ["https://a.example"] } }; + const result = buildCSP(false, "n", config, adapter); + assertEquals(result, "env-only", "env CSP has highest priority"); }); it("should ignore whitespace-only env CSP", () => { const adapter = createMockAdapter({ VERYFRONT_CSP: " " }); - const result = buildCSP(false, "n", null, null, adapter); - assert( - result.includes("default-src 'self'"), - "whitespace env should fall through to default", - ); - }); - - it("should ignore whitespace-only cspUserHeader", () => { - const result = buildCSP(false, "n", " "); + const result = buildCSP(false, "n", null, adapter); assert( result.includes("default-src 'self'"), - "whitespace header should fall through to default", + "whitespace env should fall through to the floor", ); }); it("should produce different CSPs for different nonces", () => { - const a = buildCSP(false, "nonce-aaa", null); - const b = buildCSP(false, "nonce-bbb", null); + const a = buildCSP(false, "nonce-aaa"); + const b = buildCSP(false, "nonce-bbb"); assert(a !== b, "different nonces should produce different CSPs"); assert(a.includes("'nonce-nonce-aaa'"), "first nonce embedded"); assert(b.includes("'nonce-nonce-bbb'"), "second nonce embedded"); }); - it("default CSP should contain all 15 directives", () => { - const result = buildCSP(false, "n", null); + it("default CSP should contain all 14 directives", () => { + const result = buildCSP(false, "n"); const directives = [ "default-src", "script-src", "style-src", - "style-src-elem", "style-src-attr", "img-src", "font-src", @@ -259,13 +264,13 @@ describe("security/http/response/security-handler", () => { }); it("default CSP should set frame-ancestors 'none' for non-veryfront domains", () => { - const result = buildCSP(false, "n", null, null, undefined, false); + const result = buildCSP(false, "n", null, undefined, false); const sources = parseDirectiveSources(result, "frame-ancestors"); assertEquals(sources, ["'none'"], "frame-ancestors should be 'none' for customer apps"); }); it("default CSP should allow Studio embedding when isVeryfrontDomain is true", () => { - const result = buildCSP(false, "n", null, null, undefined, true); + const result = buildCSP(false, "n", null, undefined, true); const sources = parseDirectiveSources(result, "frame-ancestors"); // Only explicit Studio hosts — no wildcards. Tenant project domains // (`{slug}.preview.veryfront.com` etc.) must NOT be able to embed @@ -300,14 +305,15 @@ describe("security/http/response/security-handler", () => { // Only these two carry a platform asset. Every other directive must be // exactly host-free, checked by exclusion so a directive added to the // policy later is covered here without anyone remembering to list it. - const mayCarryPlatformHosts = new Set(["script-src", "img-src"]); + // connect-src carries the script origins so the browser may fetch the + // source maps those modules reference. + const mayCarryPlatformHosts = new Set(["script-src", "img-src", "connect-src"]); for ( const directive of [ "default-src", "script-src", "style-src", - "style-src-elem", "img-src", "font-src", "connect-src", @@ -332,7 +338,10 @@ describe("security/http/response/security-handler", () => { ); } - assertEquals(parseDirectiveSources(csp, "connect-src"), ["'self'"]); + assertEquals( + parseDirectiveSources(csp, "connect-src"), + ["'self'", ...PLATFORM_SCRIPT_ORIGINS], + ); assertEquals(parseDirectiveSources(csp, "font-src"), ["'self'", "data:"]); }); @@ -385,19 +394,18 @@ describe("security/http/response/security-handler", () => { it("default CSP should allow inline style elements, blob workers, and blob media", () => { const csp = buildCSP(false, "my-nonce", null); - const styleElemSources = parseDirectiveSources( - csp, - "style-src-elem", - ); + // style-src governs