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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=<full comma-separated replacement list>
# FIRST_TREE_SECURITY_CSP_IMG_SRC=<full comma-separated replacement list>
# 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.
Expand Down
47 changes: 47 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<first-tree-host>/ \
| grep -iE 'content-security-policy|strict-transport-security|x-content-type-options|referrer-policy|permissions-policy|x-frame-options'
curl -sI https://<first-tree-host>/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
Expand Down
18 changes: 11 additions & 7 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions packages/qa/cases/cross-surface/browser-security-policy.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions packages/server/src/__tests__/build-app-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
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
6 changes: 6 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 { 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";
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading
Loading