From 42a5524fb0e7a54415a1554bda275f0bba5a9eca Mon Sep 17 00:00:00 2001 From: Bestony Date: Sat, 25 Jul 2026 08:55:41 +0800 Subject: [PATCH] fix(security): enforce browser headers for SPA and API --- .env.example | 15 ++ docs/cli-reference.md | 47 ++++ docs/observability.md | 18 +- .../cross-surface/browser-security-policy.md | 72 +++++++ .../__tests__/build-app-validation.test.ts | 13 ++ packages/server/src/api/attachments.ts | 1 - packages/server/src/app.ts | 6 + .../src/security/__tests__/headers.test.ts | 151 +++++++++++++ packages/server/src/security/headers.ts | 202 ++++++++++++++++++ .../config/__tests__/server-config.test.ts | 67 +++++- packages/shared/src/config/index.ts | 7 +- packages/shared/src/config/server-config.ts | 148 +++++++++++++ packages/web/index.html | 68 +----- packages/web/src/__tests__/analytics.test.ts | 81 ++++++- packages/web/src/analytics.tsx | 12 +- packages/web/src/bootstrap.ts | 104 +++++++++ 16 files changed, 929 insertions(+), 83 deletions(-) create mode 100644 packages/qa/cases/cross-surface/browser-security-policy.md create mode 100644 packages/server/src/security/__tests__/headers.test.ts create mode 100644 packages/server/src/security/headers.ts create mode 100644 packages/web/src/bootstrap.ts diff --git a/.env.example b/.env.example index 8b03541f5..a8d2e219b 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,21 @@ FIRST_TREE_HOST=0.0.0.0 # CORS allowed origins (comma-separated) # FIRST_TREE_CORS_ORIGIN=https://app.example.com +# Browser security policy origins (comma-separated or JSON arrays). Each +# configured list replaces that directive's third-party defaults; include every +# analytics, avatar, CDN, and Sentry origin used by the web build. Use [] to +# clear a list; an empty value is treated as unset. See docs/cli-reference.md. +# FIRST_TREE_SECURITY_CSP_SCRIPT_SRC=https://www.googletagmanager.com,https://www.clarity.ms,https://scripts.clarity.ms +# FIRST_TREE_SECURITY_CSP_CONNECT_SRC= +# FIRST_TREE_SECURITY_CSP_IMG_SRC= +# FIRST_TREE_SECURITY_CSP_FONT_SRC=[] +# FIRST_TREE_SECURITY_CSP_STYLE_SRC=[] +# FIRST_TREE_SECURITY_CSP_FRAME_SRC=[] +# FIRST_TREE_SECURITY_CSP_MEDIA_SRC=[] +# FIRST_TREE_SECURITY_CSP_WORKER_SRC=[] +# FIRST_TREE_SECURITY_CSP_FORM_ACTION=[] +# Add the exact origin from VITE_SENTRY_DSN to CONNECT_SRC when Web Sentry is enabled. + # Deployment-owned GitLab Web Context egress authorization (JSON; default deny). # Each exact HTTPS origin uses either {"kind":"public"} or explicit CIDRs. # Team admins cannot extend this policy from Settings. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index fbb44c79a..f37d61bc6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1574,6 +1574,53 @@ and are not used by the CLI. They are listed here for ops reference. | `FIRST_TREE_TRUST_PROXY` | Trust the reverse-proxy `X-Forwarded-*` headers. | `false` | | `FIRST_TREE_WORKSPACES_ROOT` | Where agent worktrees are materialised on the host. | derived from `FIRST_TREE_HOME` | +**Browser security policy:** + +The server adds the enforced browser security headers to API responses, health +responses, static assets, SPA fallbacks, and route errors. CSP origin lists are +exact credential-free origins. Each variable accepts either a JSON array or a +comma-separated list; wildcards and paths are rejected. Setting a list replaces +that directive's third-party defaults, so keep every origin required by the +deployed web bundle when overriding it. Use `[]` to clear a default list; an +empty environment value is treated as unset. The default third-party entries follow the +[Microsoft Clarity CSP guidance](https://learn.microsoft.com/en-us/clarity/setup-and-installation/clarity-csp) +and [Google Tag Manager CSP guidance](https://developers.google.com/tag-platform/security/guides/csp). + +| Variable | Purpose | Default | +|---|---|---| +| `FIRST_TREE_SECURITY_CSP_SCRIPT_SRC` | Allowed origins for `script-src`; setting this replaces the default list. | Google Tag Manager and Microsoft Clarity | +| `FIRST_TREE_SECURITY_CSP_CONNECT_SRC` | Allowed API/analytics/WebSocket origins; setting this replaces the default list. The configured server origin and its matching `ws` or `wss` origin are added automatically. | Google Analytics collection origins, `www.clarity.ms`, `c.bing.com`, and each exact regional origin from `a.clarity.ms` through `z.clarity.ms` | +| `FIRST_TREE_SECURITY_CSP_IMG_SRC` | Allowed remote image origins; same-origin, data, and blob images remain available; setting this replaces the default list. | GitHub/Google avatar origins, Google Analytics image origins, `www.clarity.ms`, `c.bing.com`, and each exact regional Clarity origin | +| `FIRST_TREE_SECURITY_CSP_FONT_SRC` | Allowed font origins; setting this replaces the default list. | none | +| `FIRST_TREE_SECURITY_CSP_STYLE_SRC` | Allowed stylesheet origins; setting this replaces the default list. Inline style attributes remain enabled for the existing React UI. | none | +| `FIRST_TREE_SECURITY_CSP_FRAME_SRC` | Allowed child-frame origins; setting this replaces the default list. The default is `frame-src 'none'`. | none | +| `FIRST_TREE_SECURITY_CSP_MEDIA_SRC` | Allowed audio/video origins; setting this replaces the default list. | none | +| `FIRST_TREE_SECURITY_CSP_WORKER_SRC` | Allowed worker origins; setting this replaces the default list. | none | +| `FIRST_TREE_SECURITY_CSP_FORM_ACTION` | Allowed form submission origins; setting this replaces the default list. | none | + +When a deployment adds an object-storage host for document previews, copy the +corresponding deployed default lists, append the exact object-storage origin to +`FIRST_TREE_SECURITY_CSP_CONNECT_SRC` and +`FIRST_TREE_SECURITY_CSP_IMG_SRC`, and set the complete replacement lists in +deployment configuration rather than editing the web bundle. + +If the web build enables Sentry, add the origin from its `VITE_SENTRY_DSN` to +`FIRST_TREE_SECURITY_CSP_CONNECT_SRC` as well. The DSN is a build-time web +setting, so the runtime server intentionally does not infer it from the +compiled bundle. + +The policy also sends `Strict-Transport-Security`, +`X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, and both +`frame-ancestors 'none'` and `X-Frame-Options: DENY`. Verify a running +deployment with: + +```bash +curl -sI https:/// \ + | grep -iE 'content-security-policy|strict-transport-security|x-content-type-options|referrer-policy|permissions-policy|x-frame-options' +curl -sI https:///api/v1/health \ + | grep -iE 'content-security-policy|strict-transport-security|x-content-type-options|referrer-policy|permissions-policy|x-frame-options' +``` + **Command update advertisement:** There is no `FIRST_TREE_UPDATE_CHANNEL`. Published channels have separate npm diff --git a/docs/observability.md b/docs/observability.md index bd897b533..9b9c0eb97 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -91,7 +91,9 @@ artifact. The Docker release workflow passes `VITE_SENTRY_DSN` and `VITE_SENTRY_ENVIRONMENT` from GitHub repository/environment variables into the Web build, which makes Sentry enabled by default for the managed release -path without committing the public DSN to the repo. +path without committing the public DSN to the repo. Add the DSN origin to the +server's `FIRST_TREE_SECURITY_CSP_CONNECT_SRC` list when Sentry is enabled; the +server cannot safely infer a build-time DSN from the static bundle. ### Client configuration @@ -121,12 +123,14 @@ provider prompts, model output, tool output, stdout, and stderr are redacted. The Web Console loads Microsoft Clarity for production session insights. The project id is `xj2f9syfng`. -Clarity is loaded from `packages/web/index.html` only when -`window.location.hostname` is `cloud.first-tree.ai`. Local development hosts and -staging hosts such as `dev.cloud.first-tree.ai` do not fetch the Clarity SDK and -do not write into the production project. This mirrors the GA4 host gate in the -same SPA shell and avoids requiring a Vite env var, Docker build argument, or -secret for this fixed production tag. +Clarity is loaded from the external `packages/web/src/bootstrap.ts` module only +when `window.location.hostname` is `cloud.first-tree.ai`. Local development +hosts and staging hosts such as `dev.cloud.first-tree.ai` do not fetch the +Clarity SDK and do not write into the production project. This mirrors the GA4 +host gate in the same SPA shell and avoids requiring a Vite env var, Docker +build argument, or secret for this fixed production tag. Keeping the bootstrap +external also lets the server enforce `script-src 'self'` without an inline +script exception. The Web Console treats Clarity as layout/session telemetry, not content telemetry. The React root (`#root`) carries `data-clarity-mask="true"` so chat diff --git a/packages/qa/cases/cross-surface/browser-security-policy.md b/packages/qa/cases/cross-surface/browser-security-policy.md new file mode 100644 index 000000000..eae8a26d0 --- /dev/null +++ b/packages/qa/cases/cross-surface/browser-security-policy.md @@ -0,0 +1,72 @@ +--- +id: browser-security-policy +description: Validate enforced browser security headers without breaking authenticated SPA, WebSocket, avatar, attachment, or document-preview flows. +areas: [cross-surface] +surfaces: [server, web] +--- + +# Browser Security Policy + +## Goal + +Confirm that the production server applies the complete enforced browser security policy to SPA, API, health, asset, and +error responses. Confirm through real browser boundaries that the policy does not block supported application behavior. +Use this case for changes to Content Security Policy (CSP), external browser resources, or server-wide response headers. +Stable header values and source-list construction belong in product tests. + +## Preconditions + +- Use an isolated Docker-backed run cell and a temporary worktree at the exact target ref. +- Build and run the production server image with an isolated PostgreSQL database and valid run-local secrets. +- Configure `FIRST_TREE_PUBLIC_URL` for the browser origin. Configure exact CSP origins for every run-local external + dependency. Do not use wildcards, reporting-only policies, or browser extensions that modify CSP. +- Use a throwaway account with one connected runtime. Prepare an avatar, an attachment, and a document that can be + previewed. Keep credentials, cookies, tokens, and private content out of retained evidence. +- Open browser developer tools before authentication. Preserve the Console and Network panels across navigation. + +## Operate + +1. Request the SPA root, one deep SPA route, `/api/v1/health`, a static asset, and known API and asset misses over the + production HTTP boundary. +2. Complete login and navigate through the authenticated workspace. +3. Open an existing chat, send a message, receive a runtime response, and observe a WebSocket-driven update. +4. Load local and configured remote avatars. Upload and download an attachment. +5. Open a document preview and exercise its normal navigation. +6. Attempt to embed the SPA from a separate run-local origin. +7. Inspect browser Console and Network evidence for blocked resources, CSP violations, failed WebSocket connections, and + unexpected external origins. + +## Observe + +- Every sampled response has an enforced `Content-Security-Policy`, + `Strict-Transport-Security: max-age=31536000; includeSubDomains`, + `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, + `Permissions-Policy` that disables camera, microphone, geolocation, and payment, and `X-Frame-Options: DENY`. +- CSP contains `frame-ancestors 'none'`. The separate-origin frame attempt is blocked. +- `script-src` contains neither `'unsafe-inline'` nor `'unsafe-eval'`. The shipped HTML has no inline script execution. +- Login, SPA navigation, chat send and receive, same-origin WebSocket updates, avatars, attachment transfer, and document + preview complete without CSP violations. +- Network evidence contains only same-origin requests and exact configured external origins. A newly required object + store, analytics host, avatar host, or observability host is supplied through configuration, not a wildcard. +- API, health, asset, 404, and bodyless responses retain the same security-header contract. + +## Expected Result + +`PASS` when all required headers are enforced on every sampled response, framing is denied, supported browser workflows +complete, and the Console contains no CSP violation attributable to the target. + +`FAIL` when any response omits or weakens a required header, CSP uses a wildcard or unsafe script source, the SPA can be +framed, or an in-scope workflow is blocked by the policy. + +`BLOCKED` when the isolated production stack, browser, account, connected runtime, or required test data cannot reach +`QA READY`. + +`INCONCLUSIVE` when the evidence does not cover every required response class and browser workflow, or a provider failure +cannot be separated from the target. + +## Evidence + +Keep the target commit, production image identifier, redacted response headers, representative Network request +names/statuses/origins, Console output, WebSocket state, and screenshots of successful attachment and document-preview +flows plus the blocked frame attempt. Do not retain cookies, authorization headers, tokens, private message content, or +uploaded file contents. diff --git a/packages/server/src/__tests__/build-app-validation.test.ts b/packages/server/src/__tests__/build-app-validation.test.ts index a20ba936b..f8ebd05b4 100644 --- a/packages/server/src/__tests__/build-app-validation.test.ts +++ b/packages/server/src/__tests__/build-app-validation.test.ts @@ -208,9 +208,22 @@ describe("buildApp — retired feedback route boundary", () => { expect(apiMiss.statusCode).toBe(404); expect(apiMiss.json()).toEqual({ error: "Not found" }); + const api = await app.inject({ method: "GET", url: "/api/v1/health" }); + expect(api.statusCode).toBe(200); + const assetMiss = await app.inject({ method: "GET", url: "/assets/missing.js" }); expect(assetMiss.statusCode).toBe(404); expect(assetMiss.json()).toEqual({ error: "Not found" }); + + for (const response of [spa, api, feedback, apiMiss, assetMiss]) { + expect(response.headers["content-security-policy"]).toContain("frame-ancestors 'none'"); + expect(response.headers["strict-transport-security"]).toBe("max-age=31536000; includeSubDomains"); + expect(response.headers["x-content-type-options"]).toBe("nosniff"); + expect(response.headers["referrer-policy"]).toBe("strict-origin-when-cross-origin"); + expect(response.headers["permissions-policy"]).toContain("camera=()"); + expect(response.headers["x-frame-options"]).toBe("DENY"); + } + expect(spa.headers["content-security-policy"]).not.toMatch(/script-src[^;]*unsafe-(?:inline|eval)/u); } finally { await safeClose(app); await rm(webRoot, { recursive: true, force: true }); diff --git a/packages/server/src/api/attachments.ts b/packages/server/src/api/attachments.ts index fe465c046..fc2739f63 100644 --- a/packages/server/src/api/attachments.ts +++ b/packages/server/src/api/attachments.ts @@ -51,7 +51,6 @@ export async function attachmentRoutes(app: FastifyInstance): Promise { .header("Content-Length", meta.sizeBytes) .header("Cache-Control", "private, max-age=31536000, immutable") .header("ETag", etag) - .header("X-Content-Type-Options", "nosniff") .header("Content-Disposition", `inline; filename="${encodeRfc6266Filename(meta.filename)}"`); return reply.send(data); }); diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index c0a101778..5b7f84593 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -92,6 +92,7 @@ import { reportErrorToRoot, rootLogger, } from "./observability/index.js"; +import { buildSecurityHeaders, 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"; @@ -176,6 +177,7 @@ export async function buildApp(config: Config) { // first App JWT call hours later. Cheap; only fires when the App // block is present. assertBootConfigValid(config); + const securityHeaders = buildSecurityHeaders(config); applyLoggerConfig({ level: config.observability.logging.level, @@ -195,6 +197,10 @@ export async function buildApp(config: Config) { // upstream proxy chain. trustProxy: config.trustProxy, }); + // Install the enforced browser policy before tracing, API scopes, and + // static-file serving. Root hooks also cover route misses and the SPA + // fallback, keeping API and browser responses on one security contract. + registerSecurityHeaders(app, securityHeaders); // Loud security reminder: trustProxy=true makes Fastify trust ANY upstream's // x-forwarded-for header. Safe iff the First Tree container only receives traffic diff --git a/packages/server/src/security/__tests__/headers.test.ts b/packages/server/src/security/__tests__/headers.test.ts new file mode 100644 index 000000000..2593431ce --- /dev/null +++ b/packages/server/src/security/__tests__/headers.test.ts @@ -0,0 +1,151 @@ +import { DEFAULT_SECURITY_CSP } from "@first-tree/shared/config"; +import Fastify from "fastify"; +import { describe, expect, it } from "vitest"; +import { buildSecurityHeaders, registerSecurityHeaders } from "../headers.js"; + +function directive(policy: string, name: string): string { + const value = policy + .split("; ") + .find((entry) => entry.startsWith(`${name} `)) + ?.slice(name.length + 1); + if (!value) throw new Error(`Missing CSP directive: ${name}`); + return value; +} + +function policyFrom(headers: Readonly>): string { + const policy = headers["Content-Security-Policy"]; + if (!policy) throw new Error("Missing Content-Security-Policy header"); + return policy; +} + +describe("browser security headers", () => { + it("builds the complete enforced policy with same-origin WebSocket sources", () => { + const headers = buildSecurityHeaders({ + server: { + host: "127.0.0.1", + port: 8000, + publicUrl: "https://cloud.example.test", + }, + }); + + expect(headers).toMatchObject({ + "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", + }); + + const policy = policyFrom(headers); + expect(directive(policy, "script-src")).toContain("'self'"); + expect(directive(policy, "script-src")).toContain("https://www.googletagmanager.com"); + expect(directive(policy, "script-src")).toContain("https://scripts.clarity.ms"); + expect(directive(policy, "script-src")).not.toMatch(/unsafe-(?:inline|eval)/u); + expect(directive(policy, "connect-src")).toContain("https://cloud.example.test"); + expect(directive(policy, "connect-src")).toContain("https://a.clarity.ms"); + expect(directive(policy, "connect-src")).toContain("https://z.clarity.ms"); + expect(directive(policy, "connect-src")).toContain("wss://cloud.example.test"); + expect(directive(policy, "connect-src")).not.toContain("ws://cloud.example.test"); + expect(directive(policy, "img-src")).toContain("https://c.bing.com"); + expect(directive(policy, "img-src")).toContain("https://www.googletagmanager.com"); + expect(directive(policy, "frame-ancestors")).toBe("'none'"); + expect(directive(policy, "frame-src")).toBe("'none'"); + }); + + it("uses configured origins without requiring a code change", () => { + const headers = buildSecurityHeaders({ + server: { + host: "127.0.0.1", + port: 8000, + publicUrl: undefined, + }, + security: { + csp: { + ...DEFAULT_SECURITY_CSP, + connectSrc: [...DEFAULT_SECURITY_CSP.connectSrc, "https://objects.example.test"], + imgSrc: [...DEFAULT_SECURITY_CSP.imgSrc, "https://cdn.example.test"], + }, + }, + }); + + const policy = policyFrom(headers); + expect(directive(policy, "connect-src")).toContain("ws://127.0.0.1:8000"); + expect(directive(policy, "connect-src")).not.toContain("wss://127.0.0.1:8000"); + expect(directive(policy, "connect-src")).toContain("https://objects.example.test"); + expect(directive(policy, "img-src")).toContain("https://cdn.example.test"); + }); + + it("rejects wildcard or separator-bearing sources", () => { + expect(() => + buildSecurityHeaders({ + server: { host: "127.0.0.1", port: 8000, publicUrl: undefined }, + security: { + csp: { + ...DEFAULT_SECURITY_CSP, + scriptSrc: ["https://*.example.test"], + }, + }, + }), + ).toThrow(/exact origin/u); + + expect(() => + buildSecurityHeaders({ + server: { host: "127.0.0.1", port: 8000, publicUrl: undefined }, + security: { + csp: { + ...DEFAULT_SECURITY_CSP, + connectSrc: ["https://example.test/path"], + }, + }, + }), + ).toThrow(/exact HTTP\(S\) or WebSocket origin/u); + + expect(() => + buildSecurityHeaders({ + server: { host: "127.0.0.1", port: 8000, publicUrl: undefined }, + security: { + csp: { + ...DEFAULT_SECURITY_CSP, + scriptSrc: ["wss://cdn.example.test"], + }, + }, + }), + ).toThrow(/only allowed in connect-src/u); + }); + + it("keeps the configured server origin when publicUrl has a deployment path", () => { + const headers = buildSecurityHeaders({ + server: { + host: "127.0.0.1", + port: 8000, + publicUrl: "https://cloud.example.test/console", + }, + }); + + expect(directive(policyFrom(headers), "connect-src")).toContain("https://cloud.example.test"); + }); + + it("re-applies the policy synchronously to a bodyless response", async () => { + const app = Fastify(); + const headers = buildSecurityHeaders({ + server: { + host: "127.0.0.1", + port: 8000, + publicUrl: "https://cloud.example.test", + }, + }); + registerSecurityHeaders(app, headers); + app.delete("/resource", (_request, reply) => + reply.header("Content-Security-Policy", "default-src *").code(204).send(), + ); + + try { + const response = await app.inject({ method: "DELETE", url: "/resource" }); + expect(response.statusCode).toBe(204); + expect(response.headers["content-security-policy"]).toBe(headers["Content-Security-Policy"]); + expect(response.headers["strict-transport-security"]).toBe(headers["Strict-Transport-Security"]); + } finally { + await app.close(); + } + }); +}); diff --git a/packages/server/src/security/headers.ts b/packages/server/src/security/headers.ts new file mode 100644 index 000000000..ad29edbaf --- /dev/null +++ b/packages/server/src/security/headers.ts @@ -0,0 +1,202 @@ +import { DEFAULT_SECURITY_CSP } from "@first-tree/shared/config"; +import type { FastifyInstance, FastifyReply } from "fastify"; +import type { Config } from "../config.js"; + +type CspDirectiveName = keyof typeof DEFAULT_SECURITY_CSP; + +export type SecurityCspConfig = { + readonly [K in CspDirectiveName]: readonly string[]; +}; + +export type SecurityHeaders = Readonly>; + +type SecurityConfigInput = { + readonly security?: { + readonly csp?: Partial; + }; +}; + +const HSTS_VALUE = "max-age=31536000; includeSubDomains"; +const REFERRER_POLICY_VALUE = "strict-origin-when-cross-origin"; +const PERMISSIONS_POLICY_VALUE = "camera=(), microphone=(), geolocation=(), payment=()"; + +const ORIGIN_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); +const HTTP_PROTOCOLS = new Set(["http:", "https:"]); +const CSP_DIRECTIVE_NAMES: readonly CspDirectiveName[] = [ + "scriptSrc", + "connectSrc", + "imgSrc", + "fontSrc", + "styleSrc", + "frameSrc", + "mediaSrc", + "workerSrc", + "formAction", +]; + +/** + * Resolve the optional config group without weakening the safe defaults for + * fields that were not explicitly overridden. + */ +export function resolveSecurityCspConfig(config: SecurityConfigInput): SecurityCspConfig { + const configured = config.security?.csp; + return { + scriptSrc: configured?.scriptSrc ?? DEFAULT_SECURITY_CSP.scriptSrc, + connectSrc: configured?.connectSrc ?? DEFAULT_SECURITY_CSP.connectSrc, + imgSrc: configured?.imgSrc ?? DEFAULT_SECURITY_CSP.imgSrc, + fontSrc: configured?.fontSrc ?? DEFAULT_SECURITY_CSP.fontSrc, + styleSrc: configured?.styleSrc ?? DEFAULT_SECURITY_CSP.styleSrc, + frameSrc: configured?.frameSrc ?? DEFAULT_SECURITY_CSP.frameSrc, + mediaSrc: configured?.mediaSrc ?? DEFAULT_SECURITY_CSP.mediaSrc, + workerSrc: configured?.workerSrc ?? DEFAULT_SECURITY_CSP.workerSrc, + formAction: configured?.formAction ?? DEFAULT_SECURITY_CSP.formAction, + }; +} + +/** + * Build the complete enforced policy once per app instance. The policy is + * intentionally deterministic so every SPA, API, health, and error response + * exposes the same browser contract. + */ +export function buildSecurityHeaders(config: Pick & SecurityConfigInput): SecurityHeaders { + const csp = resolveSecurityCspConfig(config); + validateConfiguredSources(csp); + + const serverSources = resolveServerSources(config); + const directives: ReadonlyArray = [ + ["default-src", ["'self'"]], + ["base-uri", ["'self'"]], + ["object-src", ["'none'"]], + ["script-src", ["'self'", ...csp.scriptSrc]], + ["style-src", ["'self'", "'unsafe-inline'", ...csp.styleSrc]], + ["img-src", ["'self'", "data:", "blob:", ...csp.imgSrc]], + ["font-src", ["'self'", "data:", ...csp.fontSrc]], + ["connect-src", ["'self'", ...serverSources, ...csp.connectSrc]], + ["frame-src", csp.frameSrc.length > 0 ? ["'self'", ...csp.frameSrc] : ["'none'"]], + ["frame-ancestors", ["'none'"]], + ["media-src", ["'self'", "blob:", ...csp.mediaSrc]], + ["worker-src", ["'self'", "blob:", ...csp.workerSrc]], + ["manifest-src", ["'self'"]], + ["form-action", ["'self'", ...csp.formAction]], + ]; + + const contentSecurityPolicy = directives.map(([name, sources]) => `${name} ${unique(sources).join(" ")}`).join("; "); + + return { + "Content-Security-Policy": contentSecurityPolicy, + "Strict-Transport-Security": HSTS_VALUE, + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": REFERRER_POLICY_VALUE, + "Permissions-Policy": PERMISSIONS_POLICY_VALUE, + "X-Frame-Options": "DENY", + }; +} + +/** + * Register the policy before any API or static-file plugin. Fastify executes + * root `onRequest` hooks for route misses and SPA fallbacks as well, so the + * headers cannot disappear on an error or deep-link response. + */ +export function registerSecurityHeaders(app: FastifyInstance, headers: SecurityHeaders): void { + app.addHook("onRequest", (_request, reply, done) => { + applySecurityHeaders(reply, headers); + done(); + }); + app.addHook("onSend", (_request, reply, payload, done) => { + // Re-apply at the last response hook so a route or plugin cannot weaken + // the app-wide policy after the request hook has run. + applySecurityHeaders(reply, headers); + done(null, payload); + }); +} + +export function applySecurityHeaders(reply: FastifyReply, headers: SecurityHeaders): void { + for (const [name, value] of Object.entries(headers)) { + reply.header(name, value); + } +} + +function validateConfiguredSources(config: SecurityCspConfig): void { + for (const directive of CSP_DIRECTIVE_NAMES) { + const sources = config[directive]; + for (const source of sources) { + if (typeof source !== "string" || source.trim().length === 0) { + throw new Error(`Invalid security.csp.${directive} source: expected a non-empty origin`); + } + const trimmed = source.trim(); + if (trimmed.includes("*") || trimmed.includes(";") || /\s/u.test(trimmed)) { + throw new Error( + `Invalid security.csp.${directive} source "${trimmed}": use an exact origin without wildcards or separators`, + ); + } + const parsed = parseOrigin(trimmed); + if (!parsed) { + throw new Error( + `Invalid security.csp.${directive} source "${trimmed}": expected an exact HTTP(S) or WebSocket origin`, + ); + } + if (directive !== "connectSrc" && !HTTP_PROTOCOLS.has(parsed.protocol)) { + throw new Error( + `Invalid security.csp.${directive} source "${trimmed}": WebSocket origins are only allowed in connect-src`, + ); + } + } + } +} + +function resolveServerSources(config: Pick): string[] { + const serverUrl = parseServerOrigin(config.server.publicUrl); + const fallback = serverUrl ?? parseLocalServerOrigin(config.server.host, config.server.port); + if (!fallback) return []; + const httpOrigin = `${fallback.protocol}//${fallback.host}`; + const websocketProtocol = fallback.protocol === "https:" ? "wss:" : "ws:"; + return [httpOrigin, `${websocketProtocol}//${fallback.host}`]; +} + +function parseLocalServerOrigin(host: string, port: number): URL | undefined { + const trimmedHost = host.trim(); + if (trimmedHost.length === 0) return undefined; + const normalizedHost = trimmedHost.includes(":") && !trimmedHost.startsWith("[") ? `[${trimmedHost}]` : trimmedHost; + try { + return new URL(`http://${normalizedHost}:${port}`); + } catch { + return undefined; + } +} + +function parseOrigin(value: string | undefined): URL | undefined { + if (!value) return undefined; + try { + const parsed = new URL(value); + if ( + !ORIGIN_PROTOCOLS.has(parsed.protocol) || + parsed.username || + parsed.password || + parsed.pathname !== "/" || + parsed.search || + parsed.hash + ) { + return undefined; + } + return parsed; + } catch { + return undefined; + } +} + +function parseServerOrigin(value: string | undefined): URL | undefined { + if (!value) return undefined; + try { + const parsed = new URL(value); + if (!HTTP_PROTOCOLS.has(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) { + return undefined; + } + return parsed; + } catch { + return undefined; + } +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/packages/shared/src/config/__tests__/server-config.test.ts b/packages/shared/src/config/__tests__/server-config.test.ts index aa3d50c06..c1d11174c 100644 --- a/packages/shared/src/config/__tests__/server-config.test.ts +++ b/packages/shared/src/config/__tests__/server-config.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { initConfig } from "../resolver.js"; -import { createServerConfigSchema, getServerConfig, serverConfigSchema } from "../server-config.js"; +import { + createServerConfigSchema, + DEFAULT_SECURITY_CSP, + getServerConfig, + serverConfigSchema, +} from "../server-config.js"; import { resetConfig, setConfig } from "../singleton.js"; describe("server config", () => { @@ -144,6 +149,66 @@ describe("server config", () => { ).rejects.toThrow(/gitlab|array/iu); }); + it("loads browser CSP origins from JSON or comma-separated environment values", async () => { + const configDir = makeTempConfigDir(); + stubRequiredProductionConfig(); + vi.stubEnv("FIRST_TREE_SECURITY_CSP_IMG_SRC", "https://cdn.example.test, https://objects.example.test"); + + const config = await initConfig({ + schema: createServerConfigSchema({ autoGenerateSecrets: false }), + role: "server", + configDir, + }); + + expect(config.security?.csp.imgSrc).toEqual(["https://cdn.example.test", "https://objects.example.test"]); + expect(config.security?.csp.scriptSrc).toContain("https://www.googletagmanager.com"); + }); + + it("allows an explicit JSON empty list to remove a directive's defaults", async () => { + const configDir = makeTempConfigDir(); + stubRequiredProductionConfig(); + vi.stubEnv("FIRST_TREE_SECURITY_CSP_SCRIPT_SRC", "[]"); + + const config = await initConfig({ + schema: createServerConfigSchema({ autoGenerateSecrets: false }), + role: "server", + configDir, + }); + + expect(config.security?.csp.scriptSrc).toEqual([]); + expect(config.security?.csp.connectSrc).toContain("https://www.googletagmanager.com"); + }); + + it("enumerates regional Clarity and Google image defaults without wildcards", async () => { + const configDir = makeTempConfigDir(); + stubRequiredProductionConfig(); + + await initConfig({ + schema: createServerConfigSchema({ autoGenerateSecrets: false }), + role: "server", + configDir, + }); + + expect(DEFAULT_SECURITY_CSP.connectSrc).toContain("https://a.clarity.ms"); + expect(DEFAULT_SECURITY_CSP.connectSrc).toContain("https://z.clarity.ms"); + expect(DEFAULT_SECURITY_CSP.imgSrc).toContain("https://c.bing.com"); + expect(DEFAULT_SECURITY_CSP.imgSrc).toContain("https://www.googletagmanager.com"); + }); + + it("rejects wildcard or path-bearing browser CSP origins", async () => { + const configDir = makeTempConfigDir(); + stubRequiredProductionConfig(); + vi.stubEnv("FIRST_TREE_SECURITY_CSP_CONNECT_SRC", '["https://*.example.test"]'); + + await expect( + initConfig({ + schema: createServerConfigSchema({ autoGenerateSecrets: false }), + role: "server", + configDir, + }), + ).rejects.toThrow(/CSP origins|wildcards/iu); + }); + 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..9da7dae5c 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -33,7 +33,12 @@ 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_SECURITY_CSP, + 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 dc72c5282..0e321abef 100644 --- a/packages/shared/src/config/server-config.ts +++ b/packages/shared/src/config/server-config.ts @@ -14,6 +14,116 @@ const optionalTrimmedStringSchema = z.preprocess((value) => { return trimmed.length > 0 ? trimmed : undefined; }, z.string().min(1).optional()); +/** + * Browser origins used by the server-owned Content-Security-Policy. + * + * The list is deliberately origin-only. Paths, credentials, wildcards, and + * arbitrary CSP keywords are rejected so an operator cannot accidentally turn + * an allowlist into a broad host or policy injection surface. Environment + * variables accept either a JSON array or a comma-separated list. + */ +function parseCspOriginList(value: unknown): unknown { + if (typeof value !== "string") return value; + const trimmed = value.trim(); + if (trimmed.length === 0) return []; + try { + const parsed: unknown = JSON.parse(trimmed); + if (Array.isArray(parsed)) return parsed; + } catch { + // Fall back to the operator-friendly comma-separated form below. + } + return trimmed + .split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); +} + +const cspOriginSchema = z + .string() + .trim() + .min(1) + .transform((raw, ctx) => { + if (raw.includes("*") || raw.includes(";")) { + ctx.addIssue({ + code: "custom", + message: "CSP origins must be exact origins (wildcards and policy separators are not allowed)", + }); + return z.NEVER; + } + try { + const parsed = new URL(raw); + if ( + !["http:", "https:", "ws:", "wss:"].includes(parsed.protocol) || + parsed.username || + parsed.password || + parsed.pathname !== "/" || + parsed.search || + parsed.hash + ) { + ctx.addIssue({ + code: "custom", + message: "CSP origins must be credential-free HTTP(S) or WebSocket origins without a path", + }); + return z.NEVER; + } + return parsed.origin.toLowerCase(); + } catch { + ctx.addIssue({ + code: "custom", + message: "CSP origins must be valid HTTP(S) or WebSocket origins", + }); + return z.NEVER; + } + }); + +const cspOriginListSchema = z.preprocess( + parseCspOriginList, + z.array(cspOriginSchema).max(64, "CSP origin lists may contain at most 64 entries").default([]), +); + +const GOOGLE_ANALYTICS_SCRIPT_ORIGINS = ["https://www.googletagmanager.com"]; +const GOOGLE_ANALYTICS_CONNECT_ORIGINS = [ + "https://www.googletagmanager.com", + "https://www.google-analytics.com", + "https://region1.google-analytics.com", + "https://analytics.google.com", + "https://region1.analytics.google.com", +]; +const GOOGLE_ANALYTICS_IMAGE_ORIGINS = [ + "https://www.googletagmanager.com", + "https://www.google-analytics.com", + "https://region1.google-analytics.com", +]; +const CLARITY_SCRIPT_ORIGINS = ["https://www.clarity.ms", "https://scripts.clarity.ms"]; +// Clarity load-balances collection across these exact hosts. CSP wildcards are +// intentionally not accepted by the operator schema, so keep the set explicit. +const CLARITY_REGIONAL_ORIGINS = Array.from("abcdefghijklmnopqrstuvwxyz", (region) => `https://${region}.clarity.ms`); +const CLARITY_DATA_ORIGINS = ["https://www.clarity.ms", "https://c.bing.com", ...CLARITY_REGIONAL_ORIGINS]; + +/** + * Defaults cover the browser dependencies shipped by the web console today. + * Deployments can replace any list through `security.csp.*`; adding a future + * object-storage or CDN host therefore changes configuration, not application + * code. + */ +export const DEFAULT_SECURITY_CSP = { + scriptSrc: [...GOOGLE_ANALYTICS_SCRIPT_ORIGINS, ...CLARITY_SCRIPT_ORIGINS], + connectSrc: [...GOOGLE_ANALYTICS_CONNECT_ORIGINS, ...CLARITY_DATA_ORIGINS], + imgSrc: [ + "https://avatars.githubusercontent.com", + "https://github.com", + "https://lh3.googleusercontent.com", + ...GOOGLE_ANALYTICS_IMAGE_ORIGINS, + ...CLARITY_DATA_ORIGINS, + ], + fontSrc: [], + styleSrc: [], + frameSrc: [], + mediaSrc: [], + workerSrc: [], + formAction: [], +} as const; + const landingCampaignRuntimeProviderSchema = runtimeProviderSchema .refine((provider) => provider === "codex" || provider === "claude-code", { message: "Landing campaign runtime provider must be codex or claude-code", @@ -219,6 +329,44 @@ export const serverConfigSchema = defineConfig({ */ publicUrl: field(z.string().optional(), { env: "FIRST_TREE_PUBLIC_URL" }), }, + /** + * Browser security policy for both the API and the static SPA. The group is + * optional to preserve the lightweight local/test config shape; the server + * applies `DEFAULT_SECURITY_CSP` when no override is supplied. Each + * explicitly configured list replaces its corresponding default; omitted + * sibling lists still receive their safe defaults. + */ + security: optional({ + csp: { + scriptSrc: field(cspOriginListSchema.default([...DEFAULT_SECURITY_CSP.scriptSrc]), { + env: "FIRST_TREE_SECURITY_CSP_SCRIPT_SRC", + }), + connectSrc: field(cspOriginListSchema.default([...DEFAULT_SECURITY_CSP.connectSrc]), { + env: "FIRST_TREE_SECURITY_CSP_CONNECT_SRC", + }), + imgSrc: field(cspOriginListSchema.default([...DEFAULT_SECURITY_CSP.imgSrc]), { + env: "FIRST_TREE_SECURITY_CSP_IMG_SRC", + }), + fontSrc: field(cspOriginListSchema.default([...DEFAULT_SECURITY_CSP.fontSrc]), { + env: "FIRST_TREE_SECURITY_CSP_FONT_SRC", + }), + styleSrc: field(cspOriginListSchema.default([...DEFAULT_SECURITY_CSP.styleSrc]), { + env: "FIRST_TREE_SECURITY_CSP_STYLE_SRC", + }), + frameSrc: field(cspOriginListSchema.default([...DEFAULT_SECURITY_CSP.frameSrc]), { + env: "FIRST_TREE_SECURITY_CSP_FRAME_SRC", + }), + mediaSrc: field(cspOriginListSchema.default([...DEFAULT_SECURITY_CSP.mediaSrc]), { + env: "FIRST_TREE_SECURITY_CSP_MEDIA_SRC", + }), + workerSrc: field(cspOriginListSchema.default([...DEFAULT_SECURITY_CSP.workerSrc]), { + env: "FIRST_TREE_SECURITY_CSP_WORKER_SRC", + }), + formAction: field(cspOriginListSchema.default([...DEFAULT_SECURITY_CSP.formAction]), { + env: "FIRST_TREE_SECURITY_CSP_FORM_ACTION", + }), + }, + }), workspace: { // Lazy default (function form): zod's `.default(value)` evaluates // `value` at schema-definition time, which would module-load-bake diff --git a/packages/web/index.html b/packages/web/index.html index 88d20e931..047625ac7 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -9,72 +9,10 @@ First Tree - - - - +
diff --git a/packages/web/src/__tests__/analytics.test.ts b/packages/web/src/__tests__/analytics.test.ts index da1cdb45e..db1f0d9dd 100644 --- a/packages/web/src/__tests__/analytics.test.ts +++ b/packages/web/src/__tests__/analytics.test.ts @@ -1,13 +1,88 @@ import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { sanitizePath } from "../analytics.js"; +import { initializeProductionAnalytics } from "../bootstrap.js"; const indexHtml = readFileSync(new URL("../../index.html", import.meta.url), "utf8"); +const bootstrapSource = readFileSync(new URL("../bootstrap.ts", import.meta.url), "utf8"); + +type TestScript = { + async?: boolean; + src?: string; +}; + +type TestAnalyticsWindow = { + clarity?: ((...args: unknown[]) => void) & { q?: unknown[][] }; + dataLayer?: IArguments[]; + gtag?: (...args: unknown[]) => void; + location: { hostname: string }; +}; + +function stubBrowser(hostname: string): { + readonly appendedScripts: TestScript[]; + readonly browserWindow: TestAnalyticsWindow; +} { + const appendedScripts: TestScript[] = []; + const browserWindow: TestAnalyticsWindow = { location: { hostname } }; + vi.stubGlobal("window", browserWindow); + vi.stubGlobal("document", { + querySelector: () => null, + createElement: () => ({}), + head: { + appendChild: (script: TestScript) => { + appendedScripts.push(script); + }, + }, + }); + return { appendedScripts, browserWindow }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); 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(bootstrapSource).toContain("dataLayer?.push(arguments)"); + expect(bootstrapSource).not.toContain("dataLayer?.push(args)"); + }); + + it("loads only the enumerated analytics scripts on the production host", () => { + const { appendedScripts, browserWindow } = stubBrowser("cloud.first-tree.ai"); + + initializeProductionAnalytics(); + + expect(appendedScripts.map((script) => script.src)).toEqual([ + "https://www.googletagmanager.com/gtag/js?id=G-BHG918MZ02", + "https://www.clarity.ms/tag/xj2f9syfng", + ]); + expect(browserWindow.dataLayer).toHaveLength(2); + expect(Array.from(browserWindow.dataLayer?.[0] ?? [])).toEqual(["js", expect.any(Date)]); + expect(Array.from(browserWindow.dataLayer?.[1] ?? [])).toEqual([ + "config", + "G-BHG918MZ02", + { + send_page_view: false, + linker: { domains: ["first-tree.ai", "cloud.first-tree.ai"] }, + }, + ]); + }); + + it("does not load analytics scripts outside the production host", () => { + const { appendedScripts, browserWindow } = stubBrowser("dev.cloud.first-tree.ai"); + + initializeProductionAnalytics(); + + expect(appendedScripts).toEqual([]); + expect(browserWindow.dataLayer).toBeUndefined(); + expect(browserWindow.gtag).toBeUndefined(); + expect(browserWindow.clarity).toBeUndefined(); + }); + + it("keeps the SPA shell free of inline scripts", () => { + expect(indexHtml).toContain(''); + expect(indexHtml).toContain(''); + expect(indexHtml).not.toMatch(/]*\bsrc=)[^>]*>/u); }); }); diff --git a/packages/web/src/analytics.tsx b/packages/web/src/analytics.tsx index 5957ec228..1e14055d7 100644 --- a/packages/web/src/analytics.tsx +++ b/packages/web/src/analytics.tsx @@ -2,9 +2,10 @@ import { useEffect } from "react"; import { useLocation } from "react-router"; /** - * GA4 analytics for the cloud SPA. The gtag snippet + config live in - * index.html (property G-BHG918MZ02, cross-domain linker, send_page_view off). - * This module reports SPA navigations and exposes a typed event helper. + * GA4 analytics for the cloud SPA. The gtag queue + config are initialized by + * the external `bootstrap.ts` module (property G-BHG918MZ02, cross-domain + * linker, send_page_view off). This module reports SPA navigations and exposes + * a typed event helper. * * Why manual page_view: gtag fires page_view once on initial load. A * react-router app changes the URL without a full load, so without this every @@ -53,8 +54,9 @@ type GtagArgs = function gtag(...args: GtagArgs): void { if (!analyticsEnabled()) return; const w = window as unknown as { gtag?: (...a: GtagArgs) => void }; - // gtag is defined inline in index.html; guard in case the snippet is absent - // (e.g. an ad-blocker removed it) so analytics never breaks the app. + // gtag is defined by the external bootstrap module; guard in case the + // snippet is absent (e.g. an ad-blocker removed it) so analytics never + // breaks the app. w.gtag?.(...args); } diff --git a/packages/web/src/bootstrap.ts b/packages/web/src/bootstrap.ts new file mode 100644 index 000000000..28f2fafb6 --- /dev/null +++ b/packages/web/src/bootstrap.ts @@ -0,0 +1,104 @@ +/** + * Browser bootstrap that must run before React mounts. + * + * Keep this in an external module instead of an inline `