From 8a8423b82e76242514b402f55ebce7610ca1925a Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Jul 2026 11:34:30 +0800 Subject: [PATCH 1/3] fix: enforce app-wide browser security headers --- .env.example | 9 ++ docs/cli-reference.md | 3 + .../browser-security-policy-journeys.md | 60 +++++++++ packages/server/package.json | 1 + packages/server/src/__tests__/helpers.ts | 7 +- .../src/__tests__/security-headers.test.ts | 120 ++++++++++++++++++ packages/server/src/app.ts | 5 + packages/server/src/security-headers.ts | 101 +++++++++++++++ .../config/__tests__/server-config.test.ts | 29 +++++ packages/shared/src/config/server-config.ts | 49 +++++++ packages/web/index.html | 71 +---------- packages/web/package.json | 3 +- packages/web/src/__tests__/analytics.test.ts | 17 ++- packages/web/src/analytics-config.ts | 1 + packages/web/src/analytics.tsx | 11 +- packages/web/src/bootstrap.ts | 56 ++++++++ pnpm-lock.yaml | 25 ++++ 17 files changed, 492 insertions(+), 76 deletions(-) create mode 100644 packages/qa/cases/cross-surface/browser-security-policy-journeys.md create mode 100644 packages/server/src/__tests__/security-headers.test.ts create mode 100644 packages/server/src/security-headers.ts create mode 100644 packages/web/src/analytics-config.ts create mode 100644 packages/web/src/bootstrap.ts diff --git a/.env.example b/.env.example index 8b03541f5..846a5aafe 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,15 @@ FIRST_TREE_HOST=0.0.0.0 # CORS allowed origins (comma-separated) # FIRST_TREE_CORS_ORIGIN=https://app.example.com +# Enforced CSP external-origin overrides (comma-separated exact HTTP(S) +# origins; no paths, credentials, or wildcards). cloud.first-tree.ai defaults +# to the production GA4, Clarity, Cloudflare, Sentry, and OAuth-avatar origins; +# every other host defaults to same-origin resources. Setting one variable +# replaces the defaults for that directive. +# FIRST_TREE_CSP_SCRIPT_ORIGINS=https://scripts.example.com +# FIRST_TREE_CSP_CONNECT_ORIGINS=https://api.example.com +# FIRST_TREE_CSP_IMAGE_ORIGINS=https://images.example.com + # 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 84c05f7e3..0b4261d59 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1574,6 +1574,9 @@ and are not used by the CLI. They are listed here for ops reference. | `FIRST_TREE_PUBLIC_URL` | Public-facing server origin. Used to stamp the issuer on short connect codes and to build invite-link URLs plus Google and GitHub OAuth callbacks. **Required in production.** | — | | `FIRST_TREE_PORTABLE_DOWNLOAD_BASE_URL` | Base URL for the prod/staging portable installer and artifact mirror. Do not include a channel suffix; the server appends the channel's `publicInstallerPath` (for example, `prod/install.sh`). | `https://download.first-tree.ai/releases` | | `FIRST_TREE_CORS_ORIGIN` | Allowed origin for the web console. | — | +| `FIRST_TREE_CSP_SCRIPT_ORIGINS` | Comma-separated exact HTTP(S) origins allowed by `script-src`. Setting it replaces the production Cloud defaults; paths, credentials, and wildcards are rejected. | Production analytics origins on `cloud.first-tree.ai`; otherwise none | +| `FIRST_TREE_CSP_CONNECT_ORIGINS` | Comma-separated exact HTTP(S) origins allowed by `connect-src`, in addition to self and the WebSocket origin derived from `FIRST_TREE_PUBLIC_URL`. Setting it replaces the production Cloud defaults. | Production analytics/monitoring origins on `cloud.first-tree.ai`; otherwise none | +| `FIRST_TREE_CSP_IMAGE_ORIGINS` | Comma-separated exact HTTP(S) origins allowed by `img-src`, in addition to self, `data:`, and `blob:`. Setting it replaces the production Cloud defaults. | Production avatar/analytics origins on `cloud.first-tree.ai`; otherwise none | | `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` | diff --git a/packages/qa/cases/cross-surface/browser-security-policy-journeys.md b/packages/qa/cases/cross-surface/browser-security-policy-journeys.md new file mode 100644 index 000000000..e77f953ac --- /dev/null +++ b/packages/qa/cases/cross-surface/browser-security-policy-journeys.md @@ -0,0 +1,60 @@ +--- +id: browser-security-policy-journeys +description: Validate enforced browser security headers without breaking the authenticated Web Console journeys or their exact external dependencies. +areas: [cross-surface] +surfaces: [server, web] +--- + +# Browser Security Policy Journeys + +## Goal + +Confirm that the production server applies one enforced browser security policy to SPA and API responses while the real +Web Console remains functional. This case owns the live browser boundary that response-injection tests cannot prove: +browser CSP enforcement, WebSocket behavior, deployed analytics, remote avatars, and blob-backed previews. + +## Preconditions + +- Run the shipped production artifact from the target ref in an isolated Docker-backed cell with HTTPS and a disposable + account, organization, agent, chat, attachment, and document. Do not reuse operator credentials or production data. +- Configure only the exact external origins the deployment actually uses. Test the production Cloud defaults separately + from a staging or localhost host, where production analytics must remain disabled. +- Use a clean browser profile without extensions. Clear the console and network log immediately before each journey and + preserve only redacted evidence. + +## Operate + +1. Request the SPA root, an SPA deep link, a successful API route, and an API error. Capture their response headers and + confirm the enforced policy is consistent across the application. +2. Sign in, open an existing chat, send a message, receive a reply over the authenticated WebSocket, and reconnect once. +3. Visit a screen with a remote member avatar. Upload and download an attachment, then open an image or document preview + that uses a `blob:` or `data:` URL. +4. On the production Cloud host, exercise a route change long enough for GA4, Clarity, Cloudflare Web Analytics, and + browser error monitoring to make their normal requests. Repeat on staging or localhost and confirm those production + analytics scripts and requests are absent. +5. From a second origin, attempt to frame the Web Console and confirm the browser refuses it. + +## Observe + +- Each sampled response includes enforced CSP, one-year HSTS with subdomains, `nosniff`, + `strict-origin-when-cross-origin`, disabled camera/microphone/geolocation/payment, and both CSP and legacy frame denial. +- `script-src` contains neither `unsafe-inline` nor `unsafe-eval`; the browser executes only external application or + explicitly configured production scripts. +- Login, routing, chat, WebSocket reconnect, avatar rendering, attachment transfer, and document preview complete with no + CSP violation, blocked-resource, mixed-content, or uncaught application error in the browser console. +- Every allowed cross-origin request matches an exact configured origin. No wildcard or unobserved analytics/CDN origin + is needed, and non-production hosts make no production analytics requests. + +## Expected Result + +`PASS` when the headers are present app-wide, framing is refused, all listed journeys work, and the browser reports zero +CSP violations with an exact minimum origin set. `FAIL` for a missing or report-only header, a policy bypass, an +unexpected external origin, or a product journey broken by enforcement. `BLOCKED` when the isolated HTTPS deployment, +browser, disposable data, or required external provider is unavailable. `INCONCLUSIVE` when browser evidence is partial +or cannot be tied to the target ref. + +## Evidence + +Keep redacted response headers, browser console output, request origin/type/status summaries, WebSocket frames limited to +message types and status, the framing refusal, and screenshots of the completed journeys. Never retain tokens, cookies, +message bodies, attachment contents, document text, or stable user and organization identifiers. diff --git a/packages/server/package.json b/packages/server/package.json index a7ddccaf0..00f6f4acf 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -29,6 +29,7 @@ "dependencies": { "@autotelic/fastify-opentelemetry": "^0.23.0", "@fastify/cors": "^11.2.0", + "@fastify/helmet": "^13.1.0", "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", "@fastify/websocket": "^11.2.0", diff --git a/packages/server/src/__tests__/helpers.ts b/packages/server/src/__tests__/helpers.ts index de5f02a26..df5f08905 100644 --- a/packages/server/src/__tests__/helpers.ts +++ b/packages/server/src/__tests__/helpers.ts @@ -82,6 +82,9 @@ export type CreateTestAppOptions = { runtimeSwitchFaultInjection?: boolean; allowedOrganizationId?: string; gitlabEgressAllowlist?: NonNullable["egressAllowlist"]; + publicUrl?: string; + security?: Config["security"]; + webDistPath?: string; /** * Drop `oauth.githubApp.slug` from the test config. Used by the * `/github-app-installation/install-url` 503 test — the slug is the @@ -141,7 +144,7 @@ export async function createTestApp(opts: CreateTestAppOptions = {}): Promise part.trim()) + .find((part) => part.startsWith(`${name} `)); + return directive?.split(/\s+/).slice(1) ?? []; +} + +function expectSecurityHeaders(headers: Record): void { + expect(headers["strict-transport-security"]).toBe("max-age=31536000; includeSubDomains"); + expect(headers["x-content-type-options"]).toBe("nosniff"); + expect(headers["referrer-policy"]).toBe("strict-origin-when-cross-origin"); + expect(headers["permissions-policy"]).toBe(PERMISSIONS_POLICY); + expect(headers["x-frame-options"]).toBe("DENY"); + + const csp = headers["content-security-policy"]; + expect(typeof csp).toBe("string"); + if (typeof csp !== "string") throw new Error("Content-Security-Policy header missing"); + + expect(cspDirective(csp, "default-src")).toEqual(["'self'"]); + expect(cspDirective(csp, "frame-ancestors")).toEqual(["'none'"]); + expect(cspDirective(csp, "script-src")).toEqual(["'self'", ...CLOUD_PRODUCTION_CSP_ORIGINS.scriptOrigins]); + expect(cspDirective(csp, "script-src-attr")).toEqual(["'none'"]); + expect(cspDirective(csp, "connect-src")).toEqual([ + "'self'", + "wss://cloud.first-tree.ai", + ...CLOUD_PRODUCTION_CSP_ORIGINS.connectOrigins, + ]); + expect(cspDirective(csp, "img-src")).toEqual([ + "'self'", + "data:", + "blob:", + ...CLOUD_PRODUCTION_CSP_ORIGINS.imageOrigins, + ]); + expect(cspDirective(csp, "script-src")).not.toContain("'unsafe-inline'"); + expect(cspDirective(csp, "script-src")).not.toContain("'unsafe-eval'"); +} + +describe("app-wide browser security headers", () => { + let app: FastifyInstance; + let webRoot: string; + + beforeAll(async () => { + webRoot = await mkdtemp(join(tmpdir(), "first-tree-security-headers-")); + await writeFile(join(webRoot, "index.html"), "App shell", "utf8"); + app = await createTestApp({ + publicUrl: "https://cloud.first-tree.ai", + webDistPath: webRoot, + }); + }); + + afterAll(async () => { + await app?.close(); + await rm(webRoot, { recursive: true, force: true }); + }); + + it("applies the enforced policy to SPA responses", async () => { + const response = await app.inject({ method: "GET", url: "/workspace/deep-link" }); + expect(response.statusCode).toBe(200); + expect(response.body).toContain("App shell"); + expectSecurityHeaders(response.headers); + }); + + it("applies the same policy to API responses", async () => { + const response = await app.inject({ method: "GET", url: "/api/v1/health" }); + expect(response.statusCode).toBe(200); + expectSecurityHeaders(response.headers); + }); + + it("applies the same policy to API error responses", async () => { + const response = await app.inject({ method: "GET", url: "/api/v1/not-a-real-route" }); + expect(response.statusCode).toBe(404); + expectSecurityHeaders(response.headers); + }); + + it("keeps non-production hosts self-only by default", () => { + const options = buildHelmetOptions({ + ...app.config, + server: { ...app.config.server, publicUrl: "https://dev.cloud.first-tree.ai" }, + security: undefined, + }); + + expect(options.contentSecurityPolicy).toMatchObject({ + directives: { + scriptSrc: ["'self'"], + connectSrc: ["'self'", "wss://dev.cloud.first-tree.ai"], + imgSrc: ["'self'", "data:", "blob:"], + }, + }); + }); + + it("uses exact operator origins in place of production defaults", () => { + const options = buildHelmetOptions({ + ...app.config, + security: { + csp: { + scriptOrigins: ["https://scripts.example.test"], + connectOrigins: ["https://api.example.test"], + imageOrigins: ["https://images.example.test"], + }, + }, + }); + + expect(options.contentSecurityPolicy).toMatchObject({ + directives: { + scriptSrc: ["'self'", "https://scripts.example.test"], + connectSrc: ["'self'", "wss://cloud.first-tree.ai", "https://api.example.test"], + imgSrc: ["'self'", "data:", "blob:", "https://images.example.test"], + }, + }); + }); +}); diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index c0a101778..9fcc46010 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 { 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"; @@ -196,6 +197,10 @@ export async function buildApp(config: Config) { trustProxy: config.trustProxy, }); + // Browser security policy is app-owned and applies uniformly to API, SPA, + // static asset, error, and not-found responses in every environment. + await registerSecurityHeaders(app, config); + // Loud security reminder: trustProxy=true makes Fastify trust ANY upstream's // x-forwarded-for header. Safe iff the First Tree container only receives traffic // through a vetted proxy (Cloudflare → CapRover). If the container is ever diff --git a/packages/server/src/security-headers.ts b/packages/server/src/security-headers.ts new file mode 100644 index 000000000..d3169bff7 --- /dev/null +++ b/packages/server/src/security-headers.ts @@ -0,0 +1,101 @@ +import helmet, { type FastifyHelmetOptions } from "@fastify/helmet"; +import type { FastifyInstance } from "fastify"; +import type { Config } from "./config.js"; + +export const PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), payment=()"; + +const CLOUD_PRODUCTION_HOST = "cloud.first-tree.ai"; +export const CLOUD_PRODUCTION_CSP_ORIGINS = { + scriptOrigins: [ + "https://www.googletagmanager.com", + "https://www.clarity.ms", + "https://scripts.clarity.ms", + "https://static.cloudflareinsights.com", + ], + connectOrigins: [ + "https://www.google-analytics.com", + "https://e.clarity.ms", + "https://o4510502633209856.ingest.us.sentry.io", + ], + imageOrigins: [ + "https://avatars.githubusercontent.com", + "https://lh3.googleusercontent.com", + "https://c.clarity.ms", + "https://c.bing.com", + ], +} as const; + +const EMPTY_CSP_ORIGINS = { + scriptOrigins: [], + connectOrigins: [], + imageOrigins: [], +} as const; + +function defaultCspOrigins( + publicUrl: string | undefined, +): typeof CLOUD_PRODUCTION_CSP_ORIGINS | typeof EMPTY_CSP_ORIGINS { + if (!publicUrl) return EMPTY_CSP_ORIGINS; + return new URL(publicUrl).hostname === CLOUD_PRODUCTION_HOST ? CLOUD_PRODUCTION_CSP_ORIGINS : EMPTY_CSP_ORIGINS; +} + +function websocketOrigin(publicUrl: string | undefined): string | undefined { + if (!publicUrl) return undefined; + const url = new URL(publicUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.origin; +} + +export function buildHelmetOptions(config: Config): FastifyHelmetOptions { + const configured = config.security?.csp; + const defaults = defaultCspOrigins(config.server.publicUrl); + const wsOrigin = websocketOrigin(config.server.publicUrl); + + return { + global: true, + contentSecurityPolicy: { + useDefaults: false, + directives: { + defaultSrc: ["'self'"], + baseUri: ["'self'"], + connectSrc: [ + "'self'", + ...(wsOrigin ? [wsOrigin] : []), + ...(configured?.connectOrigins ?? defaults.connectOrigins), + ], + fontSrc: ["'self'"], + formAction: ["'self'"], + frameAncestors: ["'none'"], + frameSrc: ["'none'"], + imgSrc: ["'self'", "data:", "blob:", ...(configured?.imageOrigins ?? defaults.imageOrigins)], + manifestSrc: ["'self'"], + mediaSrc: ["'self'", "data:", "blob:"], + objectSrc: ["'none'"], + scriptSrc: ["'self'", ...(configured?.scriptOrigins ?? defaults.scriptOrigins)], + scriptSrcAttr: ["'none'"], + styleSrc: ["'self'", "'unsafe-inline'"], + workerSrc: ["'self'", "blob:"], + }, + }, + // Cross-origin isolation is explicitly outside this hardening change. It + // needs its own compatibility pass before these headers can be enabled. + crossOriginEmbedderPolicy: false, + crossOriginOpenerPolicy: false, + crossOriginResourcePolicy: false, + originAgentCluster: false, + referrerPolicy: { policy: "strict-origin-when-cross-origin" }, + strictTransportSecurity: { + maxAge: 31_536_000, + includeSubDomains: true, + preload: false, + }, + xContentTypeOptions: true, + xFrameOptions: { action: "deny" }, + }; +} + +export async function registerSecurityHeaders(app: FastifyInstance, config: Config): Promise { + await app.register(helmet, buildHelmetOptions(config)); + app.addHook("onRequest", async (_request, reply) => { + reply.header("Permissions-Policy", PERMISSIONS_POLICY); + }); +} diff --git a/packages/shared/src/config/__tests__/server-config.test.ts b/packages/shared/src/config/__tests__/server-config.test.ts index 7aa7d9e44..d47f02878 100644 --- a/packages/shared/src/config/__tests__/server-config.test.ts +++ b/packages/shared/src/config/__tests__/server-config.test.ts @@ -133,6 +133,35 @@ describe("server config", () => { ).rejects.toThrow(/gitlab|array/iu); }); + it("loads exact CSP origins from comma-separated deployment config", async () => { + const configDir = makeTempConfigDir(); + stubRequiredProductionConfig(); + vi.stubEnv( + "FIRST_TREE_CSP_SCRIPT_ORIGINS", + " https://www.googletagmanager.com,https://www.clarity.ms,https://www.clarity.ms ", + ); + vi.stubEnv("FIRST_TREE_CSP_CONNECT_ORIGINS", "https://www.google-analytics.com,https://c.clarity.ms"); + vi.stubEnv("FIRST_TREE_CSP_IMAGE_ORIGINS", "https://AVATARS.GITHUBUSERCONTENT.COM"); + + const config = await initConfig({ + schema: createServerConfigSchema({ autoGenerateSecrets: false }), + role: "server", + configDir, + }); + + expect(config.security?.csp).toEqual({ + scriptOrigins: ["https://www.googletagmanager.com", "https://www.clarity.ms"], + connectOrigins: ["https://www.google-analytics.com", "https://c.clarity.ms"], + imageOrigins: ["https://avatars.githubusercontent.com"], + }); + }); + + it("rejects wildcard and path-based CSP sources", () => { + const schema = serverConfigSchema.security.shape.csp.scriptOrigins.schema; + expect(() => schema.parse("https://*.clarity.ms")).toThrow(/exact credential-free HTTP\(S\) origins/); + expect(() => schema.parse("https://cdn.example.com/assets")).toThrow(/exact credential-free HTTP\(S\) origins/); + }); + it("rejects partial Google OAuth configuration", async () => { const configDir = makeTempConfigDir(); stubRequiredProductionConfig(); diff --git a/packages/shared/src/config/server-config.ts b/packages/shared/src/config/server-config.ts index 411d44545..8ca79fe4b 100644 --- a/packages/shared/src/config/server-config.ts +++ b/packages/shared/src/config/server-config.ts @@ -13,6 +13,45 @@ const optionalTrimmedStringSchema = z.preprocess((value) => { return trimmed.length > 0 ? trimmed : undefined; }, z.string().min(1).optional()); +const cspOriginListSchema = z + .preprocess( + (value) => + typeof value === "string" + ? value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + : value, + z.array( + z.string().transform((raw, ctx) => { + try { + const url = new URL(raw); + if ( + (url.protocol !== "https:" && url.protocol !== "http:") || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + url.hostname.includes("*") + ) { + ctx.addIssue({ + code: "custom", + message: "CSP sources must be exact credential-free HTTP(S) origins without wildcards", + }); + return z.NEVER; + } + return url.origin; + } catch { + ctx.addIssue({ code: "custom", message: "CSP sources must be valid HTTP(S) origins" }); + return z.NEVER; + } + }), + ), + ) + .transform((origins) => [...new Set(origins)]) + .optional(); + const landingCampaignRuntimeProviderSchema = runtimeProviderSchema .refine((provider) => provider === "codex" || provider === "claude-code", { message: "Landing campaign runtime provider must be codex or claude-code", @@ -344,6 +383,16 @@ export const serverConfigSchema = defineConfig({ cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_CORS_ORIGIN" }), }), + security: optional({ + csp: { + /** Exact external origins allowed to serve executable browser scripts. */ + scriptOrigins: field(cspOriginListSchema, { env: "FIRST_TREE_CSP_SCRIPT_ORIGINS" }), + /** Exact external origins allowed for fetch, beacon, EventSource, or WebSocket connections. */ + connectOrigins: field(cspOriginListSchema, { env: "FIRST_TREE_CSP_CONNECT_ORIGINS" }), + /** Exact external origins allowed to serve avatars and other images. */ + imageOrigins: field(cspOriginListSchema, { env: "FIRST_TREE_CSP_IMAGE_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..13d7e54e0 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -8,73 +8,10 @@ First Tree - - - - - + +
diff --git a/packages/web/package.json b/packages/web/package.json index d3e8ce0ea..fb64dc80e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -36,7 +36,8 @@ "react-router": "^7.13.1", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.5.0" + "tailwind-merge": "^3.5.0", + "zod": "^4.3.6" }, "devDependencies": { "@sentry/vite-plugin": "^5.3.0", diff --git a/packages/web/src/__tests__/analytics.test.ts b/packages/web/src/__tests__/analytics.test.ts index da1cdb45e..16c6099e5 100644 --- a/packages/web/src/__tests__/analytics.test.ts +++ b/packages/web/src/__tests__/analytics.test.ts @@ -3,11 +3,24 @@ import { describe, expect, it } from "vitest"; import { sanitizePath } from "../analytics.js"; const indexHtml = readFileSync(new URL("../../index.html", import.meta.url), "utf8"); +const bootstrapSource = readFileSync(new URL("../bootstrap.ts", 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(bootstrapSource).toContain("analyticsWindow.dataLayer?.push(arguments)"); + expect(bootstrapSource).not.toContain("analyticsWindow.dataLayer?.push(args)"); + }); + + it("keeps every index script external for an enforced CSP", () => { + const scriptTags = indexHtml.match(/]*>/g) ?? []; + expect(scriptTags).toHaveLength(2); + expect(scriptTags.every((tag) => /\bsrc=/.test(tag))).toBe(true); + expect(indexHtml).toContain('src="/src/bootstrap.ts"'); + }); + + it("disables Zod runtime code generation before the application entry", () => { + expect(bootstrapSource).toContain("configureZod({ jitless: true })"); + expect(indexHtml.indexOf('src="/src/bootstrap.ts"')).toBeLessThan(indexHtml.indexOf('src="/src/main.tsx"')); }); }); diff --git a/packages/web/src/analytics-config.ts b/packages/web/src/analytics-config.ts new file mode 100644 index 000000000..27c4c2ef1 --- /dev/null +++ b/packages/web/src/analytics-config.ts @@ -0,0 +1 @@ +export const PROD_HOST = "cloud.first-tree.ai"; diff --git a/packages/web/src/analytics.tsx b/packages/web/src/analytics.tsx index 5957ec228..c8c552b1b 100644 --- a/packages/web/src/analytics.tsx +++ b/packages/web/src/analytics.tsx @@ -1,9 +1,12 @@ import { useEffect } from "react"; import { useLocation } from "react-router"; +import { PROD_HOST } from "./analytics-config.js"; + +export { PROD_HOST } from "./analytics-config.js"; /** - * GA4 analytics for the cloud SPA. The gtag snippet + config live in - * index.html (property G-BHG918MZ02, cross-domain linker, send_page_view off). + * GA4 analytics for the cloud SPA. The gtag bootstrap + config live in + * bootstrap.ts (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 @@ -21,8 +24,6 @@ import { useLocation } from "react-router"; * paths, before anything is sent. */ -export const PROD_HOST = "cloud.first-tree.ai"; - /** Only the production cloud host reports to the shared GA property. */ export function analyticsEnabled(): boolean { if (typeof window === "undefined") return false; @@ -53,7 +54,7 @@ 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 + // gtag is defined by bootstrap.ts; guard in case the script 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..15331b447 --- /dev/null +++ b/packages/web/src/bootstrap.ts @@ -0,0 +1,56 @@ +import { config as configureZod } from "zod"; +import { PROD_HOST } from "./analytics-config.js"; + +type GtagWindow = Window & { + dataLayer?: IArguments[]; + gtag?: (...args: unknown[]) => void; + clarity?: ClarityFunction; +}; + +type ClarityFunction = ((...args: unknown[]) => void) & { q?: unknown[][] }; + +// Zod's object parser otherwise probes `new Function` during module startup. +// Jitless mode uses its interpreter path and keeps the enforced CSP violation-free. +configureZod({ jitless: true }); + +function appendAsyncScript(src: string): void { + const tag = document.createElement("script"); + tag.async = true; + tag.src = src; + document.head.appendChild(tag); +} + +function applyStoredTheme(): void { + const storedTheme = localStorage.getItem("theme"); + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (storedTheme === "dark" || (!storedTheme && prefersDark)) { + document.documentElement.classList.add("dark"); + } +} + +function bootstrapProductionAnalytics(): void { + const analyticsWindow = window as GtagWindow; + analyticsWindow.dataLayer ??= []; + const gtag = function gtag(..._args: unknown[]): void { + // biome-ignore lint/complexity/noArguments: the official gtag.js queue contract uses Arguments. + analyticsWindow.dataLayer?.push(arguments); + }; + analyticsWindow.gtag = gtag; + appendAsyncScript("https://www.googletagmanager.com/gtag/js?id=G-BHG918MZ02"); + gtag("js", new Date()); + gtag("config", "G-BHG918MZ02", { + send_page_view: false, + linker: { domains: ["first-tree.ai", PROD_HOST] }, + }); + + const clarityQueue: ClarityFunction = (...args: unknown[]) => { + const queue = clarityQueue.q ?? []; + clarityQueue.q = queue; + queue.push(args); + }; + analyticsWindow.clarity ??= clarityQueue; + appendAsyncScript("https://www.clarity.ms/tag/xj2f9syfng"); +} + +applyStoredTheme(); +if (window.location.hostname === PROD_HOST) bootstrapProductionAnalytics(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b1b02311..2cd70fde4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 + '@fastify/helmet': + specifier: ^13.1.0 + version: 13.1.0 '@fastify/rate-limit': specifier: ^10.3.0 version: 10.3.0 @@ -378,6 +381,9 @@ importers: tailwind-merge: specifier: ^3.5.0 version: 3.5.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@sentry/vite-plugin': specifier: ^5.3.0 @@ -1155,6 +1161,9 @@ packages: '@fastify/forwarded@3.0.1': resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + '@fastify/helmet@13.1.0': + resolution: {integrity: sha512-SvVOU0IrzYJW1BvSkfq9G1WUdW3dnaRUvg6m0BtgGMBmML62No0VmSu087jecH58SFbicbREgZTPJ89mAguupA==} + '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} @@ -3606,6 +3615,9 @@ packages: fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + fastify@5.8.2: resolution: {integrity: sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==} @@ -3748,6 +3760,10 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + hono@4.12.27: resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} @@ -5817,6 +5833,11 @@ snapshots: '@fastify/forwarded@3.0.1': {} + '@fastify/helmet@13.1.0': + dependencies: + fastify-plugin: 6.0.0 + helmet: 8.3.0 + '@fastify/merge-json-schemas@0.2.1': dependencies: dequal: 2.0.3 @@ -8399,6 +8420,8 @@ snapshots: fastify-plugin@5.1.0: {} + fastify-plugin@6.0.0: {} + fastify@5.8.2: dependencies: '@fastify/ajv-compiler': 4.0.5 @@ -8592,6 +8615,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + helmet@8.3.0: {} + hono@4.12.27: {} hookable@6.1.0: {} From 70910207fa6eb0cb44185e22c892aa96d7f431df Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Jul 2026 11:40:32 +0800 Subject: [PATCH 2/3] test: parse index scripts structurally --- packages/web/src/__tests__/analytics.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/web/src/__tests__/analytics.test.ts b/packages/web/src/__tests__/analytics.test.ts index 16c6099e5..980b81f68 100644 --- a/packages/web/src/__tests__/analytics.test.ts +++ b/packages/web/src/__tests__/analytics.test.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { Window } from "happy-dom"; import { describe, expect, it } from "vitest"; import { sanitizePath } from "../analytics.js"; @@ -12,9 +13,12 @@ describe("production gtag bootstrap", () => { }); it("keeps every index script external for an enforced CSP", () => { - const scriptTags = indexHtml.match(/]*>/g) ?? []; - expect(scriptTags).toHaveLength(2); - expect(scriptTags.every((tag) => /\bsrc=/.test(tag))).toBe(true); + const document = new Window().document; + document.write(indexHtml); + + const scripts = [...document.scripts]; + expect(scripts).toHaveLength(2); + expect(scripts.every((script) => script.hasAttribute("src"))).toBe(true); expect(indexHtml).toContain('src="/src/bootstrap.ts"'); }); From d3e2bf658f399ed9b814ad98d9a22c8fd8a0912d Mon Sep 17 00:00:00 2001 From: Bestony Date: Sat, 25 Jul 2026 07:44:41 +0800 Subject: [PATCH 3/3] fix(security): complete browser CSP coverage --- .../browser-security-policy-journeys.md | 3 + .../src/__tests__/security-headers.test.ts | 64 ++++++++++++++++++- packages/server/src/api/attachments.ts | 1 - packages/server/src/security-headers.ts | 29 +++++++-- packages/web/index.html | 11 ++-- packages/web/public/theme-init.js | 14 ++++ packages/web/src/__tests__/analytics.test.ts | 29 ++++++++- packages/web/src/bootstrap.ts | 15 ++--- 8 files changed, 141 insertions(+), 25 deletions(-) create mode 100644 packages/web/public/theme-init.js diff --git a/packages/qa/cases/cross-surface/browser-security-policy-journeys.md b/packages/qa/cases/cross-surface/browser-security-policy-journeys.md index e77f953ac..124e98fd8 100644 --- a/packages/qa/cases/cross-surface/browser-security-policy-journeys.md +++ b/packages/qa/cases/cross-surface/browser-security-policy-journeys.md @@ -44,6 +44,9 @@ browser CSP enforcement, WebSocket behavior, deployed analytics, remote avatars, CSP violation, blocked-resource, mixed-content, or uncaught application error in the browser console. - Every allowed cross-origin request matches an exact configured origin. No wildcard or unobserved analytics/CDN origin is needed, and non-production hosts make no production analytics requests. +- Clarity may select any exact `a.clarity.ms` through `z.clarity.ms` collector without a violation. GA4 uses only its + core analytics endpoints because Google Signals and ad-personalization signals are disabled; no advertising origin is + required. ## Expected Result diff --git a/packages/server/src/__tests__/security-headers.test.ts b/packages/server/src/__tests__/security-headers.test.ts index 56bffc23e..f025a2ef9 100644 --- a/packages/server/src/__tests__/security-headers.test.ts +++ b/packages/server/src/__tests__/security-headers.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { FastifyInstance } from "fastify"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { buildHelmetOptions, CLOUD_PRODUCTION_CSP_ORIGINS, PERMISSIONS_POLICY } from "../security-headers.js"; +import { + buildHelmetOptions, + CLARITY_COLLECTOR_ORIGINS, + CLOUD_PRODUCTION_CSP_ORIGINS, + PERMISSIONS_POLICY, +} from "../security-headers.js"; import { createTestApp } from "./helpers.js"; function cspDirective(header: string, name: string): string[] { @@ -20,13 +25,20 @@ function expectSecurityHeaders(headers: Record { @@ -51,6 +64,7 @@ describe("app-wide browser security headers", () => { beforeAll(async () => { webRoot = await mkdtemp(join(tmpdir(), "first-tree-security-headers-")); await writeFile(join(webRoot, "index.html"), "App shell", "utf8"); + await writeFile(join(webRoot, "app.js"), "globalThis.firstTreeLoaded = true;", "utf8"); app = await createTestApp({ publicUrl: "https://cloud.first-tree.ai", webDistPath: webRoot, @@ -75,12 +89,58 @@ describe("app-wide browser security headers", () => { expectSecurityHeaders(response.headers); }); + it("applies the same policy to static assets", async () => { + const response = await app.inject({ method: "GET", url: "/app.js" }); + expect(response.statusCode).toBe(200); + expect(response.body).toContain("firstTreeLoaded"); + expectSecurityHeaders(response.headers); + }); + + it("supports curl-style HEAD verification on the SPA", async () => { + const response = await app.inject({ method: "HEAD", url: "/" }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe(""); + expectSecurityHeaders(response.headers); + }); + it("applies the same policy to API error responses", async () => { const response = await app.inject({ method: "GET", url: "/api/v1/not-a-real-route" }); expect(response.statusCode).toBe(404); expectSecurityHeaders(response.headers); }); + it("enumerates the complete exact GA4 and Clarity production origin set", () => { + expect(CLARITY_COLLECTOR_ORIGINS).toHaveLength(26); + expect(CLARITY_COLLECTOR_ORIGINS[0]).toBe("https://a.clarity.ms"); + expect(CLARITY_COLLECTOR_ORIGINS[25]).toBe("https://z.clarity.ms"); + expect(CLARITY_COLLECTOR_ORIGINS).toContain("https://t.clarity.ms"); + expect(CLARITY_COLLECTOR_ORIGINS.every((origin) => new URL(origin).origin === origin)).toBe(true); + + expect(CLOUD_PRODUCTION_CSP_ORIGINS.connectOrigins).toEqual( + expect.arrayContaining([ + "https://analytics.google.com", + "https://region1.analytics.google.com", + "https://region1.google-analytics.com", + "https://www.google-analytics.com", + "https://www.googletagmanager.com", + "https://cloudflareinsights.com", + ]), + ); + expect(CLOUD_PRODUCTION_CSP_ORIGINS.imageOrigins).toEqual( + expect.arrayContaining([ + "https://region1.analytics.google.com", + "https://region1.google-analytics.com", + "https://www.google-analytics.com", + "https://www.googletagmanager.com", + ]), + ); + expect( + Object.values(CLOUD_PRODUCTION_CSP_ORIGINS) + .flat() + .some((origin) => origin.includes("*")), + ).toBe(false); + }); + it("keeps non-production hosts self-only by default", () => { const options = buildHelmetOptions({ ...app.config, 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/security-headers.ts b/packages/server/src/security-headers.ts index d3169bff7..4affb6b98 100644 --- a/packages/server/src/security-headers.ts +++ b/packages/server/src/security-headers.ts @@ -5,6 +5,11 @@ import type { Config } from "./config.js"; export const PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), payment=()"; const CLOUD_PRODUCTION_HOST = "cloud.first-tree.ai"; +export const CLARITY_COLLECTOR_ORIGINS = Array.from( + { length: 26 }, + (_, index) => `https://${String.fromCharCode("a".charCodeAt(0) + index)}.clarity.ms`, +); + export const CLOUD_PRODUCTION_CSP_ORIGINS = { scriptOrigins: [ "https://www.googletagmanager.com", @@ -13,13 +18,22 @@ export const CLOUD_PRODUCTION_CSP_ORIGINS = { "https://static.cloudflareinsights.com", ], connectOrigins: [ + "https://analytics.google.com", + "https://region1.analytics.google.com", + "https://region1.google-analytics.com", "https://www.google-analytics.com", - "https://e.clarity.ms", + "https://www.googletagmanager.com", + ...CLARITY_COLLECTOR_ORIGINS, + "https://cloudflareinsights.com", "https://o4510502633209856.ingest.us.sentry.io", ], imageOrigins: [ "https://avatars.githubusercontent.com", "https://lh3.googleusercontent.com", + "https://region1.analytics.google.com", + "https://region1.google-analytics.com", + "https://www.google-analytics.com", + "https://www.googletagmanager.com", "https://c.clarity.ms", "https://c.bing.com", ], @@ -52,11 +66,14 @@ export function buildHelmetOptions(config: Config): FastifyHelmetOptions { return { global: true, + enableCSPNonces: false, contentSecurityPolicy: { useDefaults: false, + reportOnly: false, directives: { - defaultSrc: ["'self'"], - baseUri: ["'self'"], + defaultSrc: ["'none'"], + baseUri: ["'none'"], + childSrc: ["'none'"], connectSrc: [ "'self'", ...(wsOrigin ? [wsOrigin] : []), @@ -68,12 +85,14 @@ export function buildHelmetOptions(config: Config): FastifyHelmetOptions { frameSrc: ["'none'"], imgSrc: ["'self'", "data:", "blob:", ...(configured?.imageOrigins ?? defaults.imageOrigins)], manifestSrc: ["'self'"], - mediaSrc: ["'self'", "data:", "blob:"], + mediaSrc: ["'none'"], objectSrc: ["'none'"], scriptSrc: ["'self'", ...(configured?.scriptOrigins ?? defaults.scriptOrigins)], scriptSrcAttr: ["'none'"], + // The current React UI uses inline style attributes extensively. + // This exception does not authorize inline or evaluated scripts. styleSrc: ["'self'", "'unsafe-inline'"], - workerSrc: ["'self'", "blob:"], + workerSrc: ["'none'"], }, }, // Cross-origin isolation is explicitly outside this hardening change. It diff --git a/packages/web/index.html b/packages/web/index.html index 13d7e54e0..7918596a7 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -8,13 +8,14 @@ First Tree - - + +
- + + diff --git a/packages/web/public/theme-init.js b/packages/web/public/theme-init.js new file mode 100644 index 000000000..926a9c49a --- /dev/null +++ b/packages/web/public/theme-init.js @@ -0,0 +1,14 @@ +(() => { + let storedTheme = null; + try { + storedTheme = localStorage.getItem("theme"); + } catch { + // Private or sandboxed contexts may deny storage. Use the system theme. + storedTheme = null; + } + + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (storedTheme === "dark" || (!storedTheme && prefersDark)) { + document.documentElement.classList.add("dark"); + } +})(); diff --git a/packages/web/src/__tests__/analytics.test.ts b/packages/web/src/__tests__/analytics.test.ts index 980b81f68..5beeeb9d7 100644 --- a/packages/web/src/__tests__/analytics.test.ts +++ b/packages/web/src/__tests__/analytics.test.ts @@ -5,6 +5,7 @@ import { sanitizePath } from "../analytics.js"; const indexHtml = readFileSync(new URL("../../index.html", import.meta.url), "utf8"); const bootstrapSource = readFileSync(new URL("../bootstrap.ts", import.meta.url), "utf8"); +const themeInitSource = readFileSync(new URL("../../public/theme-init.js", import.meta.url), "utf8"); describe("production gtag bootstrap", () => { it("queues the official Arguments object so gtag.js processes commands", () => { @@ -19,12 +20,34 @@ describe("production gtag bootstrap", () => { const scripts = [...document.scripts]; expect(scripts).toHaveLength(2); expect(scripts.every((script) => script.hasAttribute("src"))).toBe(true); - expect(indexHtml).toContain('src="/src/bootstrap.ts"'); + expect(scripts.map((script) => script.getAttribute("src"))).toEqual(["/theme-init.js", "/src/bootstrap.ts"]); + expect(scripts.every((script) => script.textContent?.trim() === "")).toBe(true); }); - it("disables Zod runtime code generation before the application entry", () => { + it("disables Zod runtime code generation before importing the application", () => { expect(bootstrapSource).toContain("configureZod({ jitless: true })"); - expect(indexHtml.indexOf('src="/src/bootstrap.ts"')).toBeLessThan(indexHtml.indexOf('src="/src/main.tsx"')); + expect(bootstrapSource.indexOf("configureZod({ jitless: true })")).toBeLessThan( + bootstrapSource.indexOf('import("./main.js")'), + ); + expect(indexHtml).not.toContain('src="/src/main.tsx"'); + }); + + it("keeps the theme initializer blocking, external, and storage-safe", () => { + const document = new Window().document; + document.write(indexHtml); + const [themeScript] = [...document.scripts]; + + expect(themeScript?.getAttribute("src")).toBe("/theme-init.js"); + expect(themeScript?.hasAttribute("async")).toBe(false); + expect(themeScript?.hasAttribute("defer")).toBe(false); + expect(themeScript?.hasAttribute("type")).toBe(false); + expect(themeInitSource).toContain('localStorage.getItem("theme")'); + expect(themeInitSource).toContain("catch"); + }); + + it("keeps GA4 advertising endpoints disabled under the exact CSP allowlist", () => { + expect(bootstrapSource).toContain("allow_google_signals: false"); + expect(bootstrapSource).toContain("allow_ad_personalization_signals: false"); }); }); diff --git a/packages/web/src/bootstrap.ts b/packages/web/src/bootstrap.ts index 15331b447..03374e283 100644 --- a/packages/web/src/bootstrap.ts +++ b/packages/web/src/bootstrap.ts @@ -20,14 +20,6 @@ function appendAsyncScript(src: string): void { document.head.appendChild(tag); } -function applyStoredTheme(): void { - const storedTheme = localStorage.getItem("theme"); - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - if (storedTheme === "dark" || (!storedTheme && prefersDark)) { - document.documentElement.classList.add("dark"); - } -} - function bootstrapProductionAnalytics(): void { const analyticsWindow = window as GtagWindow; analyticsWindow.dataLayer ??= []; @@ -40,6 +32,8 @@ function bootstrapProductionAnalytics(): void { gtag("js", new Date()); gtag("config", "G-BHG918MZ02", { send_page_view: false, + allow_google_signals: false, + allow_ad_personalization_signals: false, linker: { domains: ["first-tree.ai", PROD_HOST] }, }); @@ -52,5 +46,8 @@ function bootstrapProductionAnalytics(): void { appendAsyncScript("https://www.clarity.ms/tag/xj2f9syfng"); } -applyStoredTheme(); if (window.location.hostname === PROD_HOST) bootstrapProductionAnalytics(); + +// Keep this as a dynamic import. It guarantees Zod is in jitless mode before +// any Shared schema or application module can evaluate. +void import("./main.js");