Skip to content

Security model

Restura’s security posture is asymmetric between platforms by virtue of capability gaps. The UI surfaces “Desktop only” badges on fields whose underlying capability isn’t available in the browser. Here’s the model.

  • Encryption keys are persisted via Electron’s safeStorage, which wraps them with the OS keychain — macOS Keychain, Windows Credential Manager, Linux libsecret.
  • Stored data (collections, history, environments, secrets) is encrypted with AES-256-GCM using a key derived from the keychain-wrapped key.
  • Secret-bearing auth fields use the SecretRef handle pattern (ADR 0007): the plaintext lives in electron/main/security/secret-handle-store.ts and the renderer only ever sees a handle ({ kind: 'handle', id, label? }). Plaintext is resolved only at wire-signing time in the main process — never enters the Zustand store, persistence layer, or exported collections.
  • Persisted state uses plaintext IndexedDB by default and is protected only by the browser’s same-origin policy.
  • Future: an opt-in passphrase prompt can derive an encryption key via PBKDF2; that UI has not shipped and is not advertised as an available capability.
  • mTLS, custom CA, SOCKS, and “Verify SSL = off” are not available — the browser sandbox doesn’t expose them.

SSRF guards on both Worker and Electron paths

Section titled “SSRF guards on both Worker and Electron paths”

Every outbound URL passes through shared/protocol/url-validation.ts, which blocks:

  • RFC 1918 (private networks).
  • RFC 6598 (CGNAT, 100.64.0.0/10).
  • Link-local 169.254/16 — covers the cloud-metadata endpoint.
  • Loopback and IPv6 loopback.
  • IPv6 unique-local (fc00::/7).
  • IPv4-mapped IPv6.

The guard is the single source of truth — before the shared-protocol refactor it had drifted between backends.

The Electron main process additionally enforces a pre-flight DNS guard: hostnames are resolved before connecting, and assertResolvedAddressAllowed is called against every record returned. Note: this is pre-flight only — it does not mitigate a true DNS-rebind that swaps records at TTL=0 between resolve and connect.

AWS SigV4, OAuth 1.0a, and WSSE are all signed in the Worker / Electron main process, not the renderer. The signature matches the exact bytes the upstream receives — proxying through a layer that re-encodes the body would silently invalidate it.

shared/protocol/header-policy.ts strips hop-by-hop headers (Connection, Keep-Alive, Proxy-Authenticate, Transfer-Encoding, TE, Trailers, Upgrade) from both directions and rejects header names with control characters.

The Electron renderer is sandboxed (sandbox: true, contextIsolation: true, nodeIntegration: false) and pinned down by two policies in electron/main/main.ts:

  • Default-deny web permissionssetPermissionRequestHandler/setPermissionCheckHandler on the default session reject every web-platform permission except clipboard-sanitized-write (copy buttons). Everything privileged goes through the validated IPC surface instead. Growing the allowlist is an explicit, test-guarded change (ADR 0026).
  • CSP — production loads enforce default-src 'self' file: with object-src 'none', worker-src 'self' file:, wasm-unsafe-eval only (QuickJS), and no unsafe-eval. The policy exists twice — a response header in electron/main/main.ts and a <meta> fallback injected at build time in vite.config.mts — and a structural test (electron/main/__tests__/security-hardening.test.ts) fails if the two ever diverge.

Pre-request and test scripts run in a QuickJS WASM sandbox (shared/scripts/script-executor.ts):

  • No DOM.
  • No filesystem.
  • No network escape — fetch and XMLHttpRequest are absent.
  • Memory cap (10 MB by default).
  • Execution time cap (5 seconds).

This matters because shared collections might carry scripts from anywhere; the sandbox guarantees they can’t exfiltrate or pivot.

Before any prompt goes to a provider, shared/protocol/ai/redaction.ts scrubs sensitive material from the context snapshot:

  • Sensitive header names (Authorization, Cookie, X-Api-Key).
  • Absolute URLs (host replaced with [REDACTED_HOST]; path retained).
  • Inline secret values where they’re identifiable.

Error reporting is on by default and can be disabled in Settings → Privacy. Desktop errors and native crashes go to Sentry only while that preference is enabled; tracing is disabled, sendDefaultPii is false, and the scrubber removes request context, hostnames, users, breadcrumb data, stack-frame locals, secrets, and file paths. The same opt-out also controls Sentry Release Health’s anonymous desktop session counts (crash-free rate and version adoption); Restura does not create a per-user or device identifier.

For the hosted web app, /api/telemetry/error is a redacted, rate-limited, allowlist-only log sink and is never stored or forwarded to a third party. Self-hosted deployments collect no server-side usage telemetry. See ADR 0027 — Telemetry and privacy-preserving usage signal for the full decision.

When Restura acts as an MCP server, every tool response is run through a redactor (electron/main/collection-export-redactor.ts) that strips secrets by name. SecretRef handles are never resolved for the MCP surface — agents see metadata, not plaintext.

The deployed Cloudflare Worker requires one of:

  • WORKER_PROXY_TOKEN set as a secret, sent by the client as X-Worker-Token.
  • REQUIRE_CF_ACCESS=true and a Cloudflare Access policy in front of the Worker.

Local dev bypasses auth only when Miniflare is detected or DEV_BYPASS_AUTH=true is set in .dev.vars. Never put DEV_BYPASS_AUTH in wrangler.jsonc (the deployed config).

Report security issues privately via GitHub security advisories. See SECURITY.md.