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.

Updated
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

Dev Preview sessions get a localhost-oriented CSP applied through session.webRequest.onHeadersReceived. It allows the inline scripts and evaluation used by development servers, permits HTTPS images, and blocks object embeds. Browser panels and the Portal load remote sites and do not receive this localhost CSP overlay; they use their separate storage partitions, navigation checks, and permission policy.

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 enables Trusted Types enforcement through CSP. The app also registers a pass-through default policy, so raw strings can be converted automatically: enforcement alone does not mean an arbitrary HTML or script string is rejected or sanitized.

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.

Note
The policy implementation explicitly trusts its input. Sanitization belongs at the content boundary, and CSP remains a separate control. Do not use the presence of a Trusted Types policy as evidence that untrusted HTML is safe.

Embedded browser isolation

The Portal and dev preview panels run on isolated embedded web surfaces, in partitions separate from the trusted app renderer. They run with nodeIntegration disabled, contextIsolation enabled and sandbox enforced; browser panels in the same project share their browser partition, the Portal uses its own persistent partition, and Dev Preview uses its configured preview partition; 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: ordinary buffered files are limited to 512 KiB; supported raster images get 25 MiB. The cap is checked before reading and against the actual buffer afterwards. Oversize responses return 413.
  8. fs.open() with O_NOFOLLOW on the original path, rejecting a final-component symlink swapped in after validation. This narrows the race window; an intermediate-directory replacement remains an acknowledged limitation. 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.

HTML, PDF, and media previews

The File Viewer uses separate protocols for richer formats. daintree-html:// resolves a registered token to a canonical root and serves sandboxed HTML with a token-scoped CSP. daintree-pdf:// accepts canonical PDF paths and serves up to 50 MiB to Chromium's PDF viewer. daintree-media:// serves contained audio and video with range requests so playback does not buffer the whole file first. These schemes have distinct headers and allowances; the generic file protocol's CSP is not their shared policy.

The protocol handlers and limits, renderer CSP, and embedded-browser policies are the source references. See File Viewer for the user workflow.

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.