Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
48 changes: 48 additions & 0 deletions packages/qa/cases/cross-surface/web-security-headers-csp.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 12 additions & 1 deletion packages/server/src/__tests__/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions packages/server/src/__tests__/build-app-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion packages/server/src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -200,6 +205,12 @@ export async function createTestApp(opts: CreateTestAppOptions = {}): Promise<Fa
slug: opts.omitGithubAppSlug ? undefined : "test-app-slug",
},
},
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",
Expand Down
224 changes: 224 additions & 0 deletions packages/server/src/__tests__/security-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
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 { describe, expect, it } from "vitest";
import { buildApp } from "../app.js";
import type { Config } from "../config.js";
import { buildContentSecurityPolicy, buildSecurityHeaders } from "../security-headers.js";

/**
* App-wide browser security headers (issue #1541).
*
* Unit tests pin the exact header values as pure functions of config;
* integration tests prove the `onSend` layer actually reaches every reply
* shape the server produces — SPA shell, SPA deep-link fallback, API JSON,
* API 404, and asset 404 — and that the config kill switch removes it.
*/

const baseConfig: Config = {
channel: "dev",
growth: {
landingPagesEnabled: false,
landingCampaignMaxAgentTurns: 1,
landingCampaignMaxEstimatedTokens: 120_000,
landingCampaignMaxTrialsPerUserPer24Hours: 5,
},
docs: { enabled: false },
cronJobs: { enabled: false },
database: { url: process.env.DATABASE_URL ?? "", provider: "external" },
server: { port: 0, host: "127.0.0.1", publicUrl: undefined },
workspace: { root: "/tmp/first-tree-test-workspaces" },
secrets: {
jwtSecret: "test-jwt-secret-key-for-vitest",
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",
},
observability: { logging: { level: "error", format: "json", bridgeToSpanLevel: "off" } },
runtime: {
agentHttpTokenEnforcement: false,
runtimeSwitchFaultInjection: false,
pollingIntervalSeconds: 5,
presenceCleanupSeconds: 60,
archiveSweepIntervalSeconds: 0,
archiveMappedIdleSeconds: 60 * 60,
notificationWebhookUrl: undefined,
},
update: {
commandVersion: "test.version",
pollIntervalMinutes: 1440,
registryUrl: "https://localhost.invalid",
},
instanceId: "test-instance",
};

const EXPECTED_STATIC_HEADERS: Record<string, string> = {
"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<string, string> {
const map = new Map<string, string>();
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<void>): Promise<void> {
const webRoot = await mkdtemp(join(tmpdir(), "first-tree-web-"));
await writeFile(join(webRoot, "index.html"), "<!doctype html><html><body>App shell</body></html>", "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();
},
);
});
});
6 changes: 6 additions & 0 deletions packages/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, {
Expand Down
Loading
Loading