diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 84c05f7e3..f56079e31 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1607,6 +1607,23 @@ server secrets even if `FIRST_TREE_CHANNEL` is omitted or defaults to `dev`. | `FIRST_TREE_AUTH_REFRESH_TOKEN_EXPIRY` | `30d` | | `FIRST_TREE_AUTH_CONNECT_TOKEN_EXPIRY` | `10m` | +**Browser security headers:** + +The server stamps app-wide browser security headers (CSP, HSTS, +`X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, +`X-Frame-Options`) on every HTTP response. The header shape is code-owned +(`packages/server/src/security-headers.ts`); configuration only pins the +third-party origin allowlists inside the Content-Security-Policy. Each list +variable accepts comma- or whitespace-separated `scheme://host[:port]` +origins (optional leading `*.` wildcard) and **replaces** its default list. + +| Variable | Purpose | Default | +|---|---|---| +| `FIRST_TREE_SECURITY_HEADERS_ENABLED` | Master switch for the app-wide header layer. Leave on; disable only while debugging a conflict with an edge-owned policy. | `true` | +| `FIRST_TREE_CSP_SCRIPT_ORIGINS` | Extra CSP `script-src` origins beyond `'self'`. | GA4 + Clarity loader origins | +| `FIRST_TREE_CSP_CONNECT_ORIGINS` | Extra CSP `connect-src` origins beyond `'self'` (same-origin API/WebSocket is always allowed). | GA4 collect, Clarity telemetry, Sentry ingest | +| `FIRST_TREE_CSP_IMG_ORIGINS` | Extra CSP `img-src` origins beyond `'self' data: blob:`. | GitHub/Google avatar hosts, GA4 pixel | + **GitHub App / OAuth:** | Variable | Purpose | diff --git a/packages/qa/cases/cross-surface/web-security-headers-csp.md b/packages/qa/cases/cross-surface/web-security-headers-csp.md new file mode 100644 index 000000000..e1bde00f5 --- /dev/null +++ b/packages/qa/cases/cross-surface/web-security-headers-csp.md @@ -0,0 +1,48 @@ +--- +id: web-security-headers-csp +description: Validate that the app-wide browser security headers protect every response without the enforced CSP breaking real web console usage. +areas: [cross-surface] +surfaces: [server, web] +--- + +# App-Wide Security Headers And Enforced CSP + +## Goal + +Confirm that the server-stamped browser security headers (Content-Security-Policy, HSTS, `X-Content-Type-Options`, +`Referrer-Policy`, `Permissions-Policy`, `X-Frame-Options`) are present on every response class **and** that the +enforced CSP does not break real web console usage in a browser. Product tests already pin the exact header values and +reply-path coverage (`packages/server/src/__tests__/security-headers.test.ts`); this case owns the judgment layer those +tests cannot see — a live browser executing the SPA under the enforced policy. + +## Preconditions + +- A running server that serves the built web dist (production image or `FIRST_TREE_WEB_DIST_PATH` boot), reached over + its real HTTP boundary. Vite dev-server-only runs do not answer this case: dev module scripts and HMR do not match + the shipped asset shape. +- A real browser with the devtools console open. CSP violations surface as console errors, not failed assertions. +- Do not disable the layer (`FIRST_TREE_SECURITY_HEADERS_ENABLED` stays default `true`). + +## Operate And Observe + +- Request `/`, a deep SPA route, an API route, and a missing asset with `curl -sI`; confirm each response carries the + full header set and that the CSP includes `frame-ancestors 'none'` and a `script-src` without `unsafe-inline` or + `unsafe-eval`. +- Load the console in the browser and complete a normal authenticated pass: sign in, open a workspace chat, send a + message, watch live updates arrive (WebSocket), and open a page that shows member/agent avatars. Watch the devtools + console for CSP violation reports the whole time. +- Confirm the theme boot still works under the external-script layout: with a dark OS/browser preference, first paint + is dark with no light flash. +- Attachments and avatar uploads round-trip: upload an image, see its preview render (`blob:`/`data:` grants), and + download an attachment. +- If validating against production (`cloud.first-tree.ai`), verify GA4 and Clarity loaders execute without violations; + on staging/local these loaders are hostname-gated off, so their absence is expected, not a failure. + +## Expected And Limitations + +- Zero CSP violation reports during the authenticated pass. A violation naming a legitimate product origin means the + configured allowlist (`FIRST_TREE_CSP_*_ORIGINS`) or its defaults must be updated — the fix is configuration, not + weakening the code-owned policy shape. +- Remote images in chat markdown from arbitrary hosts are intentionally blocked by `img-src`; a blocked third-party + image inside message markdown is expected behavior, not a regression. +- HSTS only takes effect over HTTPS; plain-HTTP local runs send the header but browsers ignore it. diff --git a/packages/server/src/__tests__/bootstrap.test.ts b/packages/server/src/__tests__/bootstrap.test.ts index efa0a9c6f..1eef4020e 100644 --- a/packages/server/src/__tests__/bootstrap.test.ts +++ b/packages/server/src/__tests__/bootstrap.test.ts @@ -1,7 +1,12 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { ServerConfig } from "@first-tree/shared/config"; +import { + DEFAULT_CSP_CONNECT_ORIGINS, + DEFAULT_CSP_IMG_ORIGINS, + DEFAULT_CSP_SCRIPT_ORIGINS, + type ServerConfig, +} from "@first-tree/shared/config"; import { afterEach, describe, expect, it, vi } from "vitest"; import { assertBootConfigValid } from "../boot-guards.js"; import { shouldAutoGenerateServerSecrets, startServer } from "../bootstrap-server.js"; @@ -48,6 +53,12 @@ const baseServerConfig: ServerConfig = { encryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", }, auth: { accessTokenExpiry: "30m", refreshTokenExpiry: "30d", connectTokenExpiry: "10m" }, + security: { + headersEnabled: true, + cspScriptOrigins: [...DEFAULT_CSP_SCRIPT_ORIGINS], + cspConnectOrigins: [...DEFAULT_CSP_CONNECT_ORIGINS], + cspImgOrigins: [...DEFAULT_CSP_IMG_ORIGINS], + }, trustProxy: false, connectBootstrap: { portableDownloadBaseUrl: "https://download.first-tree.ai/releases", diff --git a/packages/server/src/__tests__/build-app-validation.test.ts b/packages/server/src/__tests__/build-app-validation.test.ts index 508624119..4e4117986 100644 --- a/packages/server/src/__tests__/build-app-validation.test.ts +++ b/packages/server/src/__tests__/build-app-validation.test.ts @@ -1,6 +1,11 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + DEFAULT_CSP_CONNECT_ORIGINS, + DEFAULT_CSP_IMG_ORIGINS, + DEFAULT_CSP_SCRIPT_ORIGINS, +} from "@first-tree/shared/config"; import type { FastifyInstance } from "fastify"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildApp } from "../app.js"; @@ -33,6 +38,12 @@ const baseConfig: Config = { encryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", }, auth: { accessTokenExpiry: "30m", refreshTokenExpiry: "30d", connectTokenExpiry: "10m" }, + security: { + headersEnabled: true, + cspScriptOrigins: [...DEFAULT_CSP_SCRIPT_ORIGINS], + cspConnectOrigins: [...DEFAULT_CSP_CONNECT_ORIGINS], + cspImgOrigins: [...DEFAULT_CSP_IMG_ORIGINS], + }, trustProxy: false, connectBootstrap: { portableDownloadBaseUrl: "https://download.first-tree.ai/releases", diff --git a/packages/server/src/__tests__/helpers.ts b/packages/server/src/__tests__/helpers.ts index 5d37dc754..3d705679f 100644 --- a/packages/server/src/__tests__/helpers.ts +++ b/packages/server/src/__tests__/helpers.ts @@ -1,5 +1,10 @@ import type { AgentType, RuntimeProvider } from "@first-tree/shared"; -import { setConfig } from "@first-tree/shared/config"; +import { + DEFAULT_CSP_CONNECT_ORIGINS, + DEFAULT_CSP_IMG_ORIGINS, + DEFAULT_CSP_SCRIPT_ORIGINS, + setConfig, +} from "@first-tree/shared/config"; import bcrypt from "bcrypt"; import { eq } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; @@ -200,6 +205,12 @@ export async function createTestApp(opts: CreateTestAppOptions = {}): Promise = { + "strict-transport-security": "max-age=31536000; includeSubDomains", + "x-content-type-options": "nosniff", + "referrer-policy": "strict-origin-when-cross-origin", + "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=()", + "x-frame-options": "DENY", +}; + +function cspDirectives(csp: string): Map { + const map = new Map(); + for (const chunk of csp.split(";")) { + const [name, ...sources] = chunk.trim().split(/\s+/); + if (name) map.set(name, sources.join(" ")); + } + return map; +} + +describe("buildSecurityHeaders / buildContentSecurityPolicy", () => { + it("emits the complete issue-mandated header set", () => { + const headers = buildSecurityHeaders(baseConfig); + expect(headers).toMatchObject(EXPECTED_STATIC_HEADERS); + expect(headers["content-security-policy"]).toBe(buildContentSecurityPolicy(baseConfig)); + expect(Object.keys(headers)).toHaveLength(6); + }); + + it("builds a least-privilege CSP from the default origin lists", () => { + const directives = cspDirectives(buildContentSecurityPolicy(baseConfig)); + + expect(directives.get("default-src")).toBe("'self'"); + expect(directives.get("base-uri")).toBe("'self'"); + expect(directives.get("object-src")).toBe("'none'"); + expect(directives.get("frame-ancestors")).toBe("'none'"); + expect(directives.get("form-action")).toBe("'self'"); + expect(directives.get("font-src")).toBe("'self'"); + expect(directives.get("script-src")).toBe(`'self' ${DEFAULT_CSP_SCRIPT_ORIGINS.join(" ")}`); + expect(directives.get("style-src")).toBe("'self' 'unsafe-inline'"); + expect(directives.get("img-src")).toBe(`'self' data: blob: ${DEFAULT_CSP_IMG_ORIGINS.join(" ")}`); + // No publicUrl configured — no explicit ws(s) source; 'self' still covers + // the same-origin WebSocket in CSP3-era browsers. + expect(directives.get("connect-src")).toBe(`'self' ${DEFAULT_CSP_CONNECT_ORIGINS.join(" ")}`); + }); + + it("never allows unsafe-inline or unsafe-eval in script directives", () => { + const directives = cspDirectives( + buildContentSecurityPolicy({ + ...baseConfig, + security: { + ...baseConfig.security, + // Even a hostile-looking origin list cannot smuggle keyword sources: + // the shared config schema rejects non-origin entries before they + // reach this builder; here we just pin the builder's own output. + cspScriptOrigins: ["https://cdn.example.com"], + }, + }), + ); + expect(directives.get("script-src")).toBe("'self' https://cdn.example.com"); + for (const [name, sources] of directives) { + if (name.startsWith("script-")) { + expect(sources).not.toContain("unsafe-inline"); + expect(sources).not.toContain("unsafe-eval"); + } + } + }); + + it("derives the same-origin WebSocket source from server.publicUrl", () => { + const https = buildContentSecurityPolicy({ + ...baseConfig, + server: { ...baseConfig.server, publicUrl: "https://cloud.first-tree.ai" }, + }); + expect(cspDirectives(https).get("connect-src")).toContain("wss://cloud.first-tree.ai"); + + const http = buildContentSecurityPolicy({ + ...baseConfig, + server: { ...baseConfig.server, publicUrl: "http://localhost:9017" }, + }); + expect(cspDirectives(http).get("connect-src")).toContain("ws://localhost:9017"); + + const invalid = buildContentSecurityPolicy({ + ...baseConfig, + server: { ...baseConfig.server, publicUrl: "not a url" }, + }); + expect(cspDirectives(invalid).get("connect-src")).not.toMatch(/\bwss?:\/\//); + }); + + it("reflects per-environment origin overrides in the emitted policy", () => { + const directives = cspDirectives( + buildContentSecurityPolicy({ + ...baseConfig, + security: { + headersEnabled: true, + cspScriptOrigins: [], + cspConnectOrigins: ["https://*.ingest.sentry.io"], + cspImgOrigins: ["https://cdn.example.com"], + }, + }), + ); + expect(directives.get("script-src")).toBe("'self'"); + expect(directives.get("connect-src")).toBe("'self' https://*.ingest.sentry.io"); + expect(directives.get("img-src")).toBe("'self' data: blob: https://cdn.example.com"); + }); +}); + +describe("buildApp — security headers on every reply path", () => { + async function withSpaApp(config: Config, run: (app: FastifyInstance) => Promise): Promise { + const webRoot = await mkdtemp(join(tmpdir(), "first-tree-web-")); + await writeFile(join(webRoot, "index.html"), "App shell", "utf8"); + let app: FastifyInstance | undefined; + try { + app = await buildApp({ ...config, webDistPath: webRoot }); + await run(app); + } finally { + if (app) await app.close(); + await rm(webRoot, { recursive: true, force: true }); + } + } + + it("applies the full header set to SPA shell, SPA fallback, API JSON, and 404 replies", async () => { + const expectedCsp = buildContentSecurityPolicy(baseConfig); + await withSpaApp(baseConfig, async (app) => { + const responses = await Promise.all([ + app.inject({ method: "GET", url: "/" }), // SPA shell + app.inject({ method: "GET", url: "/workspace/deep-link" }), // SPA not-found fallback + app.inject({ method: "GET", url: "/healthz" }), // API JSON route + app.inject({ method: "GET", url: "/api/missing" }), // API 404 JSON + app.inject({ method: "GET", url: "/assets/missing.js" }), // asset 404 + ]); + for (const res of responses) { + expect(res.headers["content-security-policy"]).toBe(expectedCsp); + for (const [name, value] of Object.entries(EXPECTED_STATIC_HEADERS)) { + expect(res.headers[name]).toBe(value); + } + } + expect(responses[0]?.statusCode).toBe(200); + expect(responses[1]?.statusCode).toBe(200); + expect(responses[3]?.statusCode).toBe(404); + expect(responses[4]?.statusCode).toBe(404); + }); + }); + + it("omits the header layer when the config kill switch disables it", async () => { + await withSpaApp( + { + ...baseConfig, + security: { ...baseConfig.security, headersEnabled: false }, + }, + async (app) => { + const res = await app.inject({ method: "GET", url: "/" }); + expect(res.statusCode).toBe(200); + expect(res.headers["content-security-policy"]).toBeUndefined(); + expect(res.headers["strict-transport-security"]).toBeUndefined(); + expect(res.headers["x-frame-options"]).toBeUndefined(); + }, + ); + }); +}); diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 0b5455cc9..22df08ca1 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -91,6 +91,7 @@ import { reportErrorToRoot, rootLogger, } from "./observability/index.js"; +import { registerSecurityHeaders } from "./security-headers.js"; import { broadcastToAdmins } from "./services/admin-broadcast.js"; import { expiryToSeconds } from "./services/auth.js"; import { type BackgroundTasks, createBackgroundTasks } from "./services/background-tasks.js"; @@ -395,6 +396,11 @@ export async function buildApp(config: Config) { // that flips the flag participates without extra wiring. app.addHook("onSend", bodyCaptureOnSendHook); + // App-wide browser security headers (CSP, HSTS, frame denial, …) on every + // response — SPA shell, static assets, API, 404s, and error bodies alike. + // See security-headers.ts for the policy rationale (issue #1541). + registerSecurityHeaders(app, config); + // Auth hooks const userAuth = userAuthHook(db, config.secrets.jwtSecret); const agentSelector = agentSelectorHook(db, { diff --git a/packages/server/src/security-headers.ts b/packages/server/src/security-headers.ts new file mode 100644 index 000000000..5da6fbef6 --- /dev/null +++ b/packages/server/src/security-headers.ts @@ -0,0 +1,123 @@ +import type { FastifyInstance } from "fastify"; +import type { Config } from "./config.js"; + +/** + * App-wide browser security headers (issue #1541). + * + * Every HTTP response this server produces — SPA shell, static assets, + * API JSON, 404s, and error bodies — carries the same header set, so the + * security guarantee is code-owned and testable in every environment + * instead of living invisibly in edge/CDN configuration. + * + * Shape is code-owned here; only the third-party origin allowlists come + * from `config.security` (see `shared/src/config/server-config.ts`). + * Keyword sources are deliberately NOT configurable so a deployment env + * var can never introduce `'unsafe-inline'` / `'unsafe-eval'` into script + * directives. + * + * Directive rationale: + * - `default-src 'self'` — least-privilege fallback for every fetch + * directive not listed explicitly (fonts, manifests, media, workers, + * frames all collapse to same-origin). + * - `script-src` — `'self'` plus configured analytics loaders only. The + * web bundle contains no inline scripts (the former `index.html` + * bootstraps live in `/theme-init.js` and `/analytics-init.js`), so no + * nonce/hash machinery is needed. + * - `style-src 'unsafe-inline'` — React `style={}` attributes and + * library-injected inline styles are widespread and benign; the issue + * scope only forbids unsafe-inline in *script* directives. + * - `img-src data: blob:` — canvas avatar crops and pre-upload image + * previews use object/data URLs; remote images are limited to the + * configured avatar/analytics hosts, so arbitrary remote images in chat + * markdown are intentionally blocked. + * - `connect-src 'self'` covers the same-origin API; the same-origin + * WebSocket origin is derived from `server.publicUrl` and added + * explicitly for browsers that predate CSP3's scheme-upgrade matching. + * - `frame-ancestors 'none'` + `X-Frame-Options: DENY` — the product has + * no embedding use case; the authenticated dashboard must not be + * frameable. + * - `object-src 'none'`, `base-uri 'self'`, `form-action 'self'` — close + * the classic plugin/base-hijack/form-exfiltration injection routes. + */ + +/** One year, in seconds — the issue's minimum HSTS lifetime. */ +const HSTS_MAX_AGE_SECONDS = 31_536_000; + +/** + * Derive the same-origin WebSocket source from the deployment's public URL. + * Returns `undefined` when no public URL is configured (dev quickstart) — + * `connect-src 'self'` already matches same-origin `ws(s):` in CSP3-era + * browsers, so the explicit entry is a compatibility widening, not the + * primary grant. + */ +function websocketSelfSource(publicUrl: string | undefined): string | undefined { + if (!publicUrl) return undefined; + let parsed: URL; + try { + parsed = new URL(publicUrl); + } catch { + return undefined; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined; + const scheme = parsed.protocol === "https:" ? "wss" : "ws"; + return `${scheme}://${parsed.host}`; +} + +function directive(name: string, sources: (string | undefined)[]): string { + const filtered = sources.filter((source): source is string => Boolean(source)); + return `${name} ${filtered.join(" ")}`; +} + +/** Build the enforced Content-Security-Policy value for this deployment. */ +export function buildContentSecurityPolicy(config: Config): string { + const { cspScriptOrigins, cspConnectOrigins, cspImgOrigins } = config.security; + return [ + directive("default-src", ["'self'"]), + directive("base-uri", ["'self'"]), + directive("object-src", ["'none'"]), + directive("frame-ancestors", ["'none'"]), + directive("form-action", ["'self'"]), + directive("script-src", ["'self'", ...cspScriptOrigins]), + directive("style-src", ["'self'", "'unsafe-inline'"]), + directive("img-src", ["'self'", "data:", "blob:", ...cspImgOrigins]), + directive("font-src", ["'self'"]), + directive("connect-src", ["'self'", websocketSelfSource(config.server.publicUrl), ...cspConnectOrigins]), + ].join("; "); +} + +/** + * The complete header set applied to every response. Computed once at app + * build time — the values are pure functions of static config. + */ +export function buildSecurityHeaders(config: Config): Record { + return { + "content-security-policy": buildContentSecurityPolicy(config), + // Ignored by browsers over plain http (per RFC 6797), so it is safe to + // send unconditionally — local plain-http development is unaffected. + "strict-transport-security": `max-age=${HSTS_MAX_AGE_SECONDS}; includeSubDomains`, + "x-content-type-options": "nosniff", + "referrer-policy": "strict-origin-when-cross-origin", + "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=()", + // Redundant with `frame-ancestors 'none'` for CSP-aware browsers; kept + // per issue scope as the legacy-browser belt to CSP's suspenders. + "x-frame-options": "DENY", + }; +} + +/** + * Register the app-wide header layer. `onSend` (rather than `onRequest`) + * guarantees coverage of every reply path Fastify can produce: routed + * handlers, the SPA not-found fallback, plugin-thrown 4xx (rate limit), + * and the error handler. + */ +export function registerSecurityHeaders(app: FastifyInstance, config: Config): void { + if (!config.security.headersEnabled) { + app.log.warn("app-wide security headers are DISABLED via config (FIRST_TREE_SECURITY_HEADERS_ENABLED)"); + return; + } + const headers = buildSecurityHeaders(config); + app.addHook("onSend", (_request, reply, payload, done) => { + reply.headers(headers); + done(null, payload); + }); +} diff --git a/packages/shared/src/config/__tests__/server-config.test.ts b/packages/shared/src/config/__tests__/server-config.test.ts index 7aa7d9e44..feff945c9 100644 --- a/packages/shared/src/config/__tests__/server-config.test.ts +++ b/packages/shared/src/config/__tests__/server-config.test.ts @@ -133,6 +133,63 @@ describe("server config", () => { ).rejects.toThrow(/gitlab|array/iu); }); + it("defaults browser security headers to enabled with the production CSP origin lists", async () => { + const configDir = makeTempConfigDir(); + stubRequiredProductionConfig(); + const config = await initConfig({ + schema: createServerConfigSchema({ autoGenerateSecrets: false }), + role: "server", + configDir, + }); + const { DEFAULT_CSP_CONNECT_ORIGINS, DEFAULT_CSP_IMG_ORIGINS, DEFAULT_CSP_SCRIPT_ORIGINS } = await import( + "../server-config.js" + ); + expect(config.security.headersEnabled).toBe(true); + expect(config.security.cspScriptOrigins).toEqual([...DEFAULT_CSP_SCRIPT_ORIGINS]); + expect(config.security.cspConnectOrigins).toEqual([...DEFAULT_CSP_CONNECT_ORIGINS]); + expect(config.security.cspImgOrigins).toEqual([...DEFAULT_CSP_IMG_ORIGINS]); + }); + + it("parses CSP origin list env vars as comma/whitespace-separated origins that replace the defaults", async () => { + const configDir = makeTempConfigDir(); + stubRequiredProductionConfig(); + vi.stubEnv("FIRST_TREE_SECURITY_HEADERS_ENABLED", "false"); + vi.stubEnv( + "FIRST_TREE_CSP_SCRIPT_ORIGINS", + "https://CDN.Example.com, https://*.metrics.example\n https://a.example:8443", + ); + vi.stubEnv("FIRST_TREE_CSP_CONNECT_ORIGINS", "wss://relay.example"); + const config = await initConfig({ + schema: createServerConfigSchema({ autoGenerateSecrets: false }), + role: "server", + configDir, + }); + expect(config.security.headersEnabled).toBe(false); + // Replaces (not appends to) the default list, lowercased and trimmed. + expect(config.security.cspScriptOrigins).toEqual([ + "https://cdn.example.com", + "https://*.metrics.example", + "https://a.example:8443", + ]); + expect(config.security.cspConnectOrigins).toEqual(["wss://relay.example"]); + }); + + it("rejects CSP keyword sources and non-origin entries in origin list env vars", async () => { + stubRequiredProductionConfig(); + for (const invalid of ["'unsafe-inline'", "data:", "*", "javascript:alert(1)", "example.com"]) { + resetConfig(); + const configDir = makeTempConfigDir(); + vi.stubEnv("FIRST_TREE_CSP_SCRIPT_ORIGINS", invalid); + await expect( + initConfig({ + schema: createServerConfigSchema({ autoGenerateSecrets: false }), + role: "server", + configDir, + }), + ).rejects.toThrow(/CSP origin/u); + } + }); + it("rejects partial Google OAuth configuration", async () => { const configDir = makeTempConfigDir(); stubRequiredProductionConfig(); diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index 07eb795dd..ecb635102 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -33,7 +33,14 @@ export { export { defineConfig, field, optional } from "./schema.js"; export type { ServerConfig } from "./server-config.js"; // Typed config schemas and accessors -export { createServerConfigSchema, getServerConfig, serverConfigSchema } from "./server-config.js"; +export { + createServerConfigSchema, + DEFAULT_CSP_CONNECT_ORIGINS, + DEFAULT_CSP_IMG_ORIGINS, + DEFAULT_CSP_SCRIPT_ORIGINS, + getServerConfig, + serverConfigSchema, +} from "./server-config.js"; // `setConfig` is intended for test scaffolding only — production code goes // through `initConfig`, which sets the singleton internally. Exposed at the // barrel so server test helpers can pin a config before constructing the diff --git a/packages/shared/src/config/server-config.ts b/packages/shared/src/config/server-config.ts index 411d44545..866d7e470 100644 --- a/packages/shared/src/config/server-config.ts +++ b/packages/shared/src/config/server-config.ts @@ -65,6 +65,73 @@ const gitlabEgressAllowlistSchema = z.preprocess( ), ); +/** + * One CSP source expression: an exact scheme://host[:port] origin, optionally + * with a `*.` leftmost-label wildcard (`https://*.clarity.ms`). Schemes are + * restricted to http(s)/ws(s) — CSP keyword sources (`'self'`, `'none'`, …) + * and scheme-only sources (`data:`, `blob:`) are owned by the server's header + * builder, not by configuration, so an operator cannot accidentally weaken + * the policy shape (e.g. sneak in `'unsafe-inline'`) through an origin list. + */ +const cspSourceSchema = z + .string() + .trim() + .toLowerCase() + .regex(/^(?:https?|wss?):\/\/(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?$/, { + message: "CSP origin must be scheme://host[:port] (http/https/ws/wss, optional leading *. wildcard)", + }); + +/** + * Origin lists arrive from env vars as one string; accept comma- and/or + * whitespace-separated tokens. Setting a list env var REPLACES the default + * list for that directive (predictable per-environment pinning), it does not + * append to it. + */ +const cspOriginListSchema = z.preprocess((value) => { + if (typeof value !== "string") return value; + return value.split(/[\s,]+/).filter((entry) => entry.length > 0); +}, z.array(cspSourceSchema)); + +/** + * Default third-party origin allowlists for the app-wide Content-Security-Policy + * (`security` config group below). Exported so server code and tests reference + * the same single source of truth instead of copy-pasting origin arrays. + * + * Script: GA4 gtag loader + Microsoft Clarity — both production-hostname-gated + * in the web bundle, so keeping them in staging/dev CSP is inert but keeps one + * policy shape across channels. + */ +export const DEFAULT_CSP_SCRIPT_ORIGINS: readonly string[] = Object.freeze([ + "https://www.googletagmanager.com", + "https://www.clarity.ms", +]); + +/** + * Connect: GA4 collect endpoints (regional subdomains — Google requires the + * wildcards), Clarity telemetry, and Sentry ingest for the web bundle's error + * reporting. `'self'` (same-origin API + WebSocket) is code-owned, not listed. + */ +export const DEFAULT_CSP_CONNECT_ORIGINS: readonly string[] = Object.freeze([ + "https://*.google-analytics.com", + "https://*.analytics.google.com", + "https://www.googletagmanager.com", + "https://*.clarity.ms", + "https://c.bing.com", + "https://*.ingest.sentry.io", + "https://*.ingest.us.sentry.io", +]); + +/** + * Img: OAuth avatar hosts persisted by the GitHub/Google sign-in flows, plus + * the GA4 pixel fallback hosts. `'self' data: blob:` are code-owned. + */ +export const DEFAULT_CSP_IMG_ORIGINS: readonly string[] = Object.freeze([ + "https://avatars.githubusercontent.com", + "https://lh3.googleusercontent.com", + "https://*.google-analytics.com", + "https://*.googletagmanager.com", +]); + const googleOauthConfig = optional({ clientId: field(z.string().min(1), { env: "FIRST_TREE_GOOGLE_CLIENT_ID" }), clientSecret: field(z.string().min(1), { @@ -344,6 +411,43 @@ export const serverConfigSchema = defineConfig({ cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_CORS_ORIGIN" }), }), + /** + * App-wide browser security headers (CSP, HSTS, frame protection, …) set by + * the server on every HTTP response — SPA shell, static assets, and API + * alike — so every environment carries the same testable guarantee instead + * of depending on invisible edge/CDN configuration (issue #1541). + * + * The header *shape* (which directives exist, keyword sources, HSTS + * lifetime, frame denial) is code-owned in + * `@first-tree/server/security-headers`. Configuration only pins the + * third-party origin allowlists per environment, so a new legitimate origin + * (analytics host, avatar CDN, object-storage host) is a deployment config + * change, not a code edit. Defaults cover the dependencies the production + * web bundle actually uses; each env var REPLACES its default list. + */ + security: { + /** + * Master switch. Leave on everywhere; the escape hatch exists only for + * deployments that must temporarily defer to an edge-owned policy while + * debugging a conflict. + */ + headersEnabled: field(z.boolean().default(true), { env: "FIRST_TREE_SECURITY_HEADERS_ENABLED" }), + /** Extra `script-src` origins beyond `'self'`. */ + cspScriptOrigins: field(cspOriginListSchema.default([...DEFAULT_CSP_SCRIPT_ORIGINS]), { + env: "FIRST_TREE_CSP_SCRIPT_ORIGINS", + }), + /** + * Extra `connect-src` origins beyond `'self'` (which already covers the + * same-origin API and WebSocket). + */ + cspConnectOrigins: field(cspOriginListSchema.default([...DEFAULT_CSP_CONNECT_ORIGINS]), { + env: "FIRST_TREE_CSP_CONNECT_ORIGINS", + }), + /** Extra `img-src` origins beyond `'self' data: blob:`. */ + cspImgOrigins: field(cspOriginListSchema.default([...DEFAULT_CSP_IMG_ORIGINS]), { + env: "FIRST_TREE_CSP_IMG_ORIGINS", + }), + }, /** * Trust upstream proxy headers (e.g. `x-forwarded-for`) for `req.ip`. Required * in production where First Tree sits behind Cloudflare / a reverse proxy — otherwise diff --git a/packages/web/index.html b/packages/web/index.html index 88d20e931..09160c388 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -9,72 +9,23 @@ First Tree - - - - + +
diff --git a/packages/web/public/analytics-init.js b/packages/web/public/analytics-init.js new file mode 100644 index 000000000..421e67abe --- /dev/null +++ b/packages/web/public/analytics-init.js @@ -0,0 +1,63 @@ +/** + * Analytics boot (GA4 + Microsoft Clarity). Loaded as an external deferred + * same-origin script from index.html rather than inline blocks: the + * app-wide CSP forbids inline scripts (`script-src 'self' …`, no + * `unsafe-inline` — see packages/server/src/security-headers.ts). The + * third-party loader origins below must stay in sync with the CSP + * `script-src` allowlist defaults (`FIRST_TREE_CSP_SCRIPT_ORIGINS`). + * + * Google Analytics 4 — same property (G-BHG918MZ02) as first-tree.ai, with + * cross-domain linking so a visitor who goes marketing-site -> cloud is + * stitched into one user (that's what makes "click_app -> signup" + * attributable per repo/campaign). send_page_view is off: this is a + * react-router SPA, so RouteTracker (src/analytics.tsx) reports page_view on + * every route change — leaving it on would double-count the first screen. + * + * PRODUCTION ONLY: the Docker build produces one web dist for every channel, + * so we gate here on hostname. Without this, dev (127.0.0.1) and staging + * (dev.cloud.first-tree.ai) would load gtag and write into the shared + * production property, polluting the attribution dataset. gtag is neither + * fetched nor configured off the production host. analytics.tsx applies the + * same host gate before sending, as defense in depth. + * + * Microsoft Clarity — session insights for the production Web Console. + * Hostname-gated for the same reason; staging/local must not write into the + * production project. The React app root is masked in index.html + * (`data-clarity-mask`) so customer/workspace text is not uploaded in + * Clarity recordings by default. + */ +(() => { + if (window.location.hostname !== "cloud.first-tree.ai") return; + + // GA4 + window.dataLayer = window.dataLayer || []; + // Keep the official gtag queue shape. gtag.js consumes the function's + // Arguments object; pushing the rest-parameter Array leaves commands + // queued without producing GA collect requests in production. + window.gtag = function gtag() { + // biome-ignore lint/complexity/noArguments: the official gtag.js queue contract uses Arguments. + window.dataLayer.push(arguments); + }; + const gaTag = document.createElement("script"); + gaTag.async = true; + gaTag.src = "https://www.googletagmanager.com/gtag/js?id=G-BHG918MZ02"; + document.head.appendChild(gaTag); + window.gtag("js", new Date()); + window.gtag("config", "G-BHG918MZ02", { + send_page_view: false, + linker: { domains: ["first-tree.ai", "cloud.first-tree.ai"] }, + }); + + // Microsoft Clarity + window.clarity = + window.clarity || + ((...args) => { + const queue = window.clarity.q || []; + window.clarity.q = queue; + queue.push(args); + }); + const clarityTag = document.createElement("script"); + clarityTag.async = true; + clarityTag.src = "https://www.clarity.ms/tag/xj2f9syfng"; + document.head.appendChild(clarityTag); +})(); diff --git a/packages/web/public/theme-init.js b/packages/web/public/theme-init.js new file mode 100644 index 000000000..398146fb6 --- /dev/null +++ b/packages/web/public/theme-init.js @@ -0,0 +1,13 @@ +/** + * Theme boot — applies the dark class before first paint so a dark-mode user + * never sees a light flash. Loaded as a synchronous (blocking) same-origin + * script from index.html rather than an inline block: the app-wide CSP + * forbids inline scripts (`script-src 'self' …`, no `unsafe-inline` — see + * packages/server/src/security-headers.ts), and being external keeps it + * enforceable without nonce plumbing through the static file server. + */ +(() => { + const t = localStorage.getItem("theme"); + const m = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (t === "dark" || (!t && m)) document.documentElement.classList.add("dark"); +})(); diff --git a/packages/web/src/__tests__/analytics.test.ts b/packages/web/src/__tests__/analytics.test.ts index da1cdb45e..8cac2eb83 100644 --- a/packages/web/src/__tests__/analytics.test.ts +++ b/packages/web/src/__tests__/analytics.test.ts @@ -2,12 +2,15 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { sanitizePath } from "../analytics.js"; -const indexHtml = readFileSync(new URL("../../index.html", import.meta.url), "utf8"); +// The GA4/Clarity bootstrap moved out of index.html into an external file so +// the app-wide CSP can enforce `script-src 'self'` without inline allowances +// (see public/analytics-init.js and csp.test.ts). +const analyticsInit = readFileSync(new URL("../../public/analytics-init.js", import.meta.url), "utf8"); describe("production gtag bootstrap", () => { it("queues the official Arguments object so gtag.js processes commands", () => { - expect(indexHtml).toContain("window.dataLayer.push(arguments)"); - expect(indexHtml).not.toContain("window.dataLayer.push(args)"); + expect(analyticsInit).toContain("window.dataLayer.push(arguments)"); + expect(analyticsInit).not.toContain("window.dataLayer.push(args)"); }); }); diff --git a/packages/web/src/__tests__/csp.test.ts b/packages/web/src/__tests__/csp.test.ts new file mode 100644 index 000000000..c75b8e5f4 --- /dev/null +++ b/packages/web/src/__tests__/csp.test.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync } from "node:fs"; +import { DEFAULT_CSP_SCRIPT_ORIGINS } from "@first-tree/shared/config"; +import { Window } from "happy-dom"; +import { describe, expect, it } from "vitest"; + +/** + * Regression net for the enforced app-wide Content-Security-Policy + * (`packages/server/src/security-headers.ts`, issue 1541). + * + * The server sends `script-src 'self' ` with no + * `unsafe-inline` and no nonces, which is only viable while index.html keeps + * ZERO inline scripts. Anyone re-adding an inline bootstrap (the pre-CSP + * layout) would ship a page whose script silently never runs in production. + * These tests fail that change at CI time instead. + */ + +const indexHtml = readFileSync(new URL("../../index.html", import.meta.url), "utf8"); + +/** + * Every ` cannot slip past the scan (CodeQL js/bad-tag-filter). + */ +function scriptTags(html: string): Array<{ src: string | null; body: string }> { + const window = new Window(); + try { + const doc = new window.DOMParser().parseFromString(html, "text/html"); + return Array.from(doc.querySelectorAll("script"), (tag) => ({ + src: tag.getAttribute("src"), + body: tag.textContent ?? "", + })); + } finally { + window.close(); + } +} + +describe("index.html under enforced CSP (script-src 'self', no unsafe-inline)", () => { + it("contains only external scripts — every