Skip to main content

Browser and Content Security

How Daintree constrains what rendered content can do: Content Security Policy, Trusted Types, embedded browser isolation, permission lockdown, the daintree-file protocol, and what happens when something tries to open an external link.

Reviewed

Content Security Policy

Response headers

Every response served through the app:// protocol carries Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy: credentialless, Cross-Origin-Resource-Policy: same-origin and X-Content-Type-Options: nosniff. Together these block cross-origin data leaks and MIME-type confusion on every asset the app serves.

Webview CSP

Browser panel and dev preview sessions get a per-session CSP applied through session.webRequest.onHeadersReceived. The policy restricts scripts, connections and media to localhost origins and blocks object embeds and base-URI manipulation. The Portal is exempt from the localhost restriction because it loads external AI services by design; its isolation leans on partition separation and permission lockdown instead.

The shared persist:daintree session

Every project view in a window shares one Electron session, persist:daintree. The shared session lets V8 reuse its compiled-code cache across project and worktree switches, which is the dominant cost of opening one. CSP is enforced at the session level rather than per window, so every renderer load served through app:// picks up the locked-down policy with no per-view wiring.

The policy is applied in two layers: a webRequest.onHeadersReceived listener that overlays the production CSP onto every response, and a matching <meta http-equiv="Content-Security-Policy"> tag baked into the HTML at build time. If the two ever drift, Chromium intersects them into the stricter effective policy. The header is the source of truth; the meta tag covers the case where headers fail to attach.

The production policy includes require-trusted-types-for 'script' and trusted-types daintree-svg default 'allow-duplicates', which is what switches on the enforcement described next.

Trusted Types

Production Daintree runs Trusted Types in enforce mode, not report-only. Any DOM sink write (innerHTML, outerHTML, document.write, the Worker constructor URL) that is not a TrustedHTML or TrustedScript value throws a TypeError at runtime.

Two policies are registered:

  • daintree-svg: a named policy every SVG string passes through. Each string reaching it comes from a compile-time constant or an upstream sanitizer, so the policy is a checkpoint rather than a sanitizer. Re-sanitizing here would only mask regressions in the upstream validator.
  • default: a pass-through that exists because React DOM and the popover library inject inline-style strings into sinks the renderer does not control directly. Without it those writes would throw and the app would not boot.

Enforcement is renderer-only. Embedded webviews get their own narrower per-partition policies, so it does not propagate into Portal or dev preview. The renderer throws hard if window.trustedTypes is unavailable rather than degrading silently, so a missing sink check cannot hide behind a graceful fallback.

Tip
Trusted Types running in enforce mode means any new code path that writes HTML or scripts to a DOM sink fails loudly during development unless it goes through the policy. A runtime error while you are writing the code is the cheapest review a DOM XSS surface can get.

Embedded browser isolation

The Portal and dev preview panels run on isolated embedded web surfaces, each in its own partition. They run with nodeIntegration disabled, contextIsolation enabled and sandbox enforced; they have their own storage partitions, so cookies and local storage stay isolated; they cannot reach Daintree's internal APIs; they have preload scripts stripped, so they cannot inherit the main window's bridge; navigateOnDragDrop is disabled, closing drag-and-drop navigation hijacking; and the Blink auxclick feature is disabled, closing the middle-click navigation bypass.

Navigation is locked down through will-navigate and will-redirect handlers. Dev preview allows localhost URLs only. Browser panels block unsafe URLs. Any window.open() from a webview is denied, and a safe URL is routed to the OS browser instead, through the allowlist below.

Permission lockdown

Electron's permission system lets web content request access to browser APIs like the clipboard, camera and microphone. Daintree enforces per-session allowlists, so each context gets only what it needs.

SessionAllowed permissions
App renderer (default)clipboard-read, clipboard-sanitized-write, media
persist:daintree (project views)clipboard-read, clipboard-sanitized-write, media
persist:portalclipboard-sanitized-write only
persist:browser-* (one per project)None
persist:dev-preview-*None

Browser sessions are per project rather than a single shared partition, and they are created lazily when a webview first attaches. That means they cannot be locked down eagerly at startup; a session-created handler classifies each new partition as it appears and applies the same restrictions. Any permission not on a session's allowlist is denied, and every denial is logged with the session label, the permission and the requesting origin.

The daintree-file:// protocol

This custom protocol serves user-supplied files into the renderer for previews and attachments. Both the file path and the root it must live under come from the caller, so the handler treats the caller as the attacker and validates end to end. Every request runs a fixed containment chain, in order:

  1. Method check: only GET and HEAD; everything else returns 405.
  2. Null-byte check on both the path and the root.
  3. Absolute-path check on both. Relative paths are rejected without resolution.
  4. Normalization, collapsing .. segments.
  5. fs.realpath() on both, so a symlink to somewhere outside that happens to sit inside the root no longer counts as inside it.
  6. Relative-containment check: the resolved path must be a descendant of the resolved root.
  7. Size cap: files over 512 KiB return 413.
  8. fs.open() with O_NOFOLLOW on the original path, closing the window between the realpath check and the read where a symlink could be swapped in as the final component. The flag is a no-op on Windows, where realpath containment carries the weight.

Successful responses carry a hardened header set: a Content-Type picked from an extension allowlist rather than content sniffing, a Content-Length from the actual buffer, Content-Security-Policy: sandbox; default-src 'none' so the served file cannot navigate, script or fetch whatever MIME type it declares, Cross-Origin-Resource-Policy: cross-origin so the renderer can load it across schemes, X-Content-Type-Options: nosniff, and Cache-Control: no-store.

Containment failures return 404, not 403. Failing closed without telegraphing whether the path or the root was the problem keeps probe responses uniform.

The simpler app:// protocol that serves the renderer's own bundle runs a narrower check (resolved paths must fall under the bundled output directory) and returns 403 on failure. It serves built artifacts only, so the wider surface does not apply.

Path-bearing IPC handlers

Handlers that accept a renderer-supplied path share one containment helper. It requires an absolute path, resolves both the target and every allowed root through realpath at call time, and returns the canonical path so the caller hands the resolved path to the sink rather than the original string, which shrinks the window between check and use.

Sinks that launch a file rather than display it carry an extra guard, and it exists because containment alone is not enough. A file named notes.txt that is a symlink to Evil.app, sitting legitimately inside an allowed root, passes every containment check. So the executable-extension deny-list is applied twice (once on the raw input and once on the realpath-resolved target), closing that bypass. The list is per platform: .app, .command, .scpt, .pkg, .dmg and friends on macOS; .desktop, .sh, .appimage, .run on Linux; a longer set including .exe, .bat, .ps1, .lnk, .hta and .reg on Windows. Reveal-only and read-only sinks do not need it: they display the file, they do not run it.

Every outbound shell.openExternal call is funneled through one allowlist. Only http:, https: and mailto: are permitted on every platform, plus ms-windows-store: and ms-settings: on Windows and x-apple.systempreferences: on macOS. Each platform-specific scheme is gated to the platform that owns it, because allowlisting them everywhere would widen the surface on platforms where a third-party app could register the scheme itself.

An unparseable URL or an unlisted protocol throws before shell.openExternal is reached. Renderer-supplied authentication URLs are validated the same way before any side effect runs, rather than after.