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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1571,6 +1571,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` |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
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.
- 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

`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.
1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion packages/server/src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export type CreateTestAppOptions = {
runtimeSwitchFaultInjection?: boolean;
allowedOrganizationId?: string;
gitlabEgressAllowlist?: NonNullable<Config["gitlab"]>["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
Expand Down Expand Up @@ -135,7 +138,7 @@ export async function createTestApp(opts: CreateTestAppOptions = {}): Promise<Fa
server: {
port: 0,
host: "127.0.0.1",
publicUrl: undefined,
publicUrl: opts.publicUrl,
},
workspace: {
root: "/tmp/first-tree-test-workspaces",
Expand Down Expand Up @@ -167,6 +170,7 @@ export async function createTestApp(opts: CreateTestAppOptions = {}): Promise<Fa
addressPolicy: { kind: "public" as const },
})),
},
...(opts.security !== undefined ? { security: opts.security } : {}),
...(opts.allowedOrganizationId !== undefined
? { access: { allowedOrganizationId: opts.allowedOrganizationId.trim() || undefined } }
: {}),
Expand Down Expand Up @@ -232,6 +236,7 @@ export async function createTestApp(opts: CreateTestAppOptions = {}): Promise<Fa
registryUrl: "https://localhost.invalid",
},
instanceId: "test-instance",
...(opts.webDistPath !== undefined ? { webDistPath: opts.webDistPath } : {}),
};
if (opts.githubOAuth === false && config.oauth) Reflect.deleteProperty(config.oauth, "githubApp");
// Pin the singleton so service-layer helpers that go through
Expand Down
180 changes: 180 additions & 0 deletions packages/server/src/__tests__/security-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
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,
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[] {
const directive = header
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${name} `));
return directive?.split(/\s+/).slice(1) ?? [];
}

function expectSecurityHeaders(headers: Record<string, string | string[] | number | undefined>): 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");
expect(headers["content-security-policy-report-only"]).toBeUndefined();

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(["'none'"]);
expect(cspDirective(csp, "base-uri")).toEqual(["'none'"]);
expect(cspDirective(csp, "child-src")).toEqual(["'none'"]);
expect(cspDirective(csp, "frame-ancestors")).toEqual(["'none'"]);
expect(cspDirective(csp, "frame-src")).toEqual(["'none'"]);
expect(cspDirective(csp, "media-src")).toEqual(["'none'"]);
expect(cspDirective(csp, "object-src")).toEqual(["'none'"]);
expect(cspDirective(csp, "worker-src")).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'");
expect(csp).not.toContain("*");
}

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"), "<!doctype html><html><body>App shell</body></html>", "utf8");
await writeFile(join(webRoot, "app.js"), "globalThis.firstTreeLoaded = true;", "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 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,
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"],
},
});
});
});
1 change: 0 additions & 1 deletion packages/server/src/api/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ export async function attachmentRoutes(app: FastifyInstance): Promise<void> {
.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);
});
Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading