Murasaki
Building & Distribution

Security

Murasaki's trust boundaries, built-in runtime protections, and application responsibilities.

Desktop security starts with process boundaries. Murasaki keeps Node out of the renderer, restricts the application WebView to its own origin, and protects privileged loopback endpoints with native-issued per-window authority. These controls reduce the impact of ordinary renderer bugs, but they are not a complete permission system.

Native renderer commands are deny-by-default and granted with the typed capabilities list. Renderer-to-Node/API access is separately deny-by-default through backendCapabilities. Every declared window has its own allowlists. URL, path, target-window, and OS-permission scopes are available for the commands that accept those resources. Treat every renderer-to-Node function as privileged application code and keep its surface small.

Trust boundaries

BoundaryBuilt-in protectionYour responsibility
Remote URL → app WebViewExact app-origin navigation stays in-window; off-origin HTTP(S) opens in the system browser; file:, data:, and javascript: navigations are deniedDo not weaken navigation behavior or inject remote scripts into the renderer
Renderer → Rust native commandsExact-origin IPC check and a typed, deny-by-default allowlist evaluated for the calling windowGrant only the commands that renderer uses; validate app-level intent before broad operations such as window:manage
Renderer → local NodePrivileged /api/* and /__murasaki/* endpoints require an HMAC-derived identity for the exact native window plus its backendCapabilities; Host/Origin/Fetch Metadata and wire bounds are validatedGrant only the exact module export, method/path, updater, event, or diagnostics resource needed; still validate object-level authorization inside handlers
OS URL/file activation → Node MainOnly configured schemes/extensions are normalized into openRequested() targets; delivery uses the authenticated app-local channelTreat every URL and path as attacker-controlled; validate intent, identifiers, hosts, and file contents before acting
App → update hostEd25519 signature over raw manifest bytes, then SHA-256 verification of the selected payloadProtect the private update key and publish hashes/signatures together
OS → installed appmacOS Developer ID/notarization and Windows Authenticode orchestration sign and verify final app-owned artifactsSupply and protect your platform certificate/provider; test trust policy and installed artifacts on clean machines

Production listens on 127.0.0.1, not all interfaces. Development accepts loopback localhost, 127.0.0.1, or [::1] hosts and rejects cross-site Fetch Metadata. Static document responses contain no runtime bearer credential. At document start the native host injects only that window's derived identity. Packaged identities bind the label and native generation; closing a window revokes it in Node, and recreating the same label receives a different HMAC. Changing either field invalidates the token, and secondary windows inherit no backend grants. The identity bootstrap exits before defining any credential in subframes or when the document origin differs from the exact app loopback origin; this is enforced even on Windows, where WebView2 injects document-start scripts into frames despite a main-frame-only request. Native lifecycle endpoints use a separate token that is never given to renderer JavaScript.

WebView network and private sessions

webview.userAgent, webview.incognito, and webview.proxy are app-wide native browser settings, not renderer permissions. They do not expand the native command allowlist or relax Murasaki's same-origin navigation/IPC checks.

  • A private/incognito session prevents the WebView from using the app's persistent browser profile. It does not hide the user's IP address, erase files written by Node Main, or provide anonymity from the app's server.
  • Every window label has a separate WebContext/profile. This prevents a secondary window's Service Worker, SharedWorker, cookies, or Web Storage from observing the primary window's authenticated backend requests. Recreating the same label reuses its process context. Incognito labels are non-persistent; on macOS 11–13 secondary labels are also non-persistent because custom persistent stores require macOS 14.
  • A custom User-Agent is sent to origins the application requests and can add fingerprinting information. Replacing the platform User-Agent may also break server compatibility or authentication flows.
  • An HTTP CONNECT or SOCKSv5 proxy can observe destination and traffic metadata; unencrypted HTTP traffic is visible to it. Trust the proxy operator and keep TLS validation enabled in the operating-system WebView.
  • Proxy credentials are not accepted. Do not encode secrets in host or add them to bundle metadata; Murasaki rejects URLs, credentials, and unknown proxy fields before startup and validates the endpoint again in Rust.

See Configuration for size limits and the macOS/WebView2 version requirements.

Grant native commands explicitly

murasaki.config.ts
export default defineConfig({
  appId: 'com.example.notes',
  productName: 'Notes',
  window: {
    route: '/',
    capabilities: [
      'app:quit',
      'dialog:openFile',
      'secureStorage:get',
      'secureStorage:set',
      'secureStorage:delete',
      'globalShortcut:register',
      'globalShortcut:unregister',
      {
        permission: 'shell:openExternal',
        allow: { urls: ['https://docs.example.com/**'] },
        deny: { urls: ['https://docs.example.com/internal/**'] },
      },
      { permission: 'window:open', allow: { windows: ['settings'] } },
    ],
  },
  windows: {
    preview: { route: '/preview', capabilities: ['clipboard:writeText'] },
    settings: { route: '/settings', capabilities: [] },
  },
})

Omitting capabilities grants nothing, except that the primary window falls back to the legacy top-level list when window.capabilities is absent. Secondary windows never inherit that list. Unknown strings grant nothing, and the bridge accepts calls only from the exact trusted application origin. The structured policy supports these resource fields:

PermissionScope field
shell:openExternal, webview:readCookies, webview:writeCookiesurls with exact URLs or an HTTP(S) trailing /** subtree
shell:showItemInFolderabsolute, non-traversing paths, exact or with trailing /**
window:open, window:manageexact declared windows labels
systemPermission:status, systemPermission:requestexact permissions names
secureStorage:get, secureStorage:set, secureStorage:deleteexact keys, or a key prefix ending in one *

An explicit deny wins over allow. A structured grant with only deny allows other values, while a legacy string grant remains unrestricted for its command. Other commands, including dialogs, remain command-level. Keep broad grants on trusted renderers and enforce narrower application intent in Node. Application-wide programmatic shutdown is separately gated by app:quit.

Grant backend resources explicitly

Native capabilities and backend capabilities protect different boundaries. Use backendCapabilities for renderer requests to Node Main, Server Actions, API Routes, updater routes, event streams, and renderer diagnostics:

murasaki.config.ts
window: {
  backendCapabilities: [
    'main:src/backend/account.ts#loadAccount',
    'action:src/actions/save.ts#saveDocument',
    'api:POST:/api/documents/*',
    'events:sync.*',
    'diagnostics:renderer-error',
  ],
},
windows: {
  preview: { route: '/preview', backendCapabilities: [] },
},

Grants match exactly unless they end in one trailing *, which is a prefix wildcard. API grants include the uppercase HTTP method. The primary uses window.backendCapabilities ?? backendCapabilities ?? []; every secondary window defaults to []. main:*, action:*, api:*, updater:*, and events:* are convenient but broad—prefer exact resources in production. Native-only shutdown, activation, and window-control endpoints cannot be granted to a renderer.

Runtime window creation is available only to trusted Node Main and only for labels declared in configuration. The private native transport carries a method and label, not a URL or capability list; the Rust host uses the immutable route and policy from the configured template. Renderer windows.open() stays show-only and cannot instantiate a dormant or destroyed window.

Global shortcuts are also command-scoped rather than accelerator-scoped. globalShortcut:register lets that renderer request any accelerator accepted by the bounded native parser, while globalShortcut:unregister can remove only registrations owned by the calling renderer. Murasaki rejects duplicate and reserved chords and releases registrations on owner close, but it cannot reserve a chord already held by another application. Keep registration in a trusted renderer, use stable ids, and treat availability errors as expected.

Store renderer-needed secrets in the OS credential store

murasaki/native exposes secureStorage.get/set/delete for short string values that trusted renderer code must use. The native host writes only to macOS Keychain or Windows Credential Manager. It derives the service/account identifier from appId and key, enforces non-empty/NUL-free UTF-8 limits, and has no plaintext fallback. Missing values return null; deletion is idempotent. Linux returns unsupported.

Grant secureStorage:get, secureStorage:set, and secureStorage:delete separately. Prefer a structured key scope so a renderer can reach only the entries it owns:

{ permission: 'secureStorage:get', allow: { keys: ['account:*', 'theme'] } }

A string grant remains key-unrestricted for compatibility. OS credential storage protects data at rest, not against XSS executing with a granted key scope.

Keys are limited to 256 UTF-8 bytes and values to 2,048 UTF-8 bytes; both must be non-empty and contain no NUL. The overall native IPC body is capped at 256 KiB. Keep appId stable or the new namespace will no longer find existing entries.

Keep server-only secrets in Node

Anything imported by renderer code can become public client JavaScript. Keep API tokens, private keys, database credentials, and license validation in src/main.ts, 'use main', 'use server', or server-only API route modules.

src/backend/account.ts
'use main'

export async function loadAccount(accountId: string) {
  if (!/^[a-z0-9_-]{1,64}$/i.test(accountId)) {
    throw new TypeError('invalid account id')
  }

  const token = process.env.ACCOUNT_API_TOKEN
  if (!token) throw new Error('account service is not configured')

  // Call the remote service from Node. Return only what the renderer needs.
  const response = await fetch(`https://api.example.com/accounts/${accountId}`, {
    headers: { authorization: `Bearer ${token}` },
    signal: AbortSignal.timeout(10_000),
  })
  if (!response.ok) throw new Error(`account service returned ${response.status}`)
  return response.json()
}

Window authority proves which native-created renderer sent a request. It does not prove that renderer is uncompromised. XSS can still invoke every backend resource granted to that window, so keep grants and handler-level object authorization narrow.

Validate paths and URLs

Never concatenate renderer input into an unrestricted filesystem path. Resolve it beneath an application-owned directory and verify that the result stays inside that directory. Prefer opaque IDs over raw paths.

Likewise, allowlist protocols and hosts before a Node function calls fetch() or opens a URL. This prevents accidental local-network access and SSRF. Do not forward arbitrary request headers from the renderer to a remote service.

The same rule applies to openRequested(). A registered scheme is a dispatch mechanism, not an authentication mechanism: any local process, browser, or document can attempt to open it. File associations also identify a path, not a trusted file.

src/main.ts
import { defineMain } from 'murasaki/main'

export default defineMain({
  async openRequested(_context, event) {
    for (const target of event.targets) {
      if (target.kind === 'url') {
        const url = new URL(target.url)
        if (url.protocol !== 'example-notes:' || url.hostname !== 'open') continue
        const id = url.pathname.slice(1)
        if (!/^[a-z0-9_-]{1,64}$/i.test(id)) continue
        // Resolve the validated identifier in application code.
      } else {
        // Check the expected extension, access policy, size, and file format
        // before parsing target.path. Do not execute content based on its name.
      }
    }
  },
})

Do not automatically navigate the renderer to an arbitrary deep-link URL, interpolate its query values into HTML, or execute a file because it carries a registered extension. If the renderer needs the result, send a narrow, validated value through the Main event API.

Content Security Policy

Murasaki sets X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer on the document, and delivers the resolved CSP two ways: as a Content-Security-Policy response header on every served HTML document (dev's Vite middleware and the packaged app's Node server both set it), and as a CSP meta tag injected into framework-owned and user-owned HTML. Both come from the same resolved policy, so they cannot drift apart. The production default uses script-src 'self', blocks objects, frames, base URL changes, inline script attributes, and form posts to other origins, denies framing this app's documents (frame-ancestors 'none'), while allowing remote-backed applications to connect over HTTPS/WSS. Images, fonts, and media support the common HTTPS/data/blob sources. style-src 'self' 'unsafe-inline' remains for React style attributes and runtime CSS compatibility.

Development uses a separate policy: it additionally permits inline scripts inserted by Vite/React Refresh and ws: connections for HMR. It does not add 'unsafe-eval'. This development relaxation is not present in production.

Override the complete policy, or opt out when another layer owns it:

export default defineConfig({
  appId: 'com.example.notes',
  productName: 'Notes',
  security: {
    csp: "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'none'; frame-src 'none'; frame-ancestors 'none'",
  },
})

// Disable both the header and the meta tag:
// security: { csp: false }

A string is a complete override, not a directive merge, and it applies to both delivery mechanisms identically. If a user-owned index.html already has a CSP meta tag, Murasaki keeps it and moves it to the beginning of <head>. In this case (a CSP meta tag with no security.csp configured), Murasaki delivers the policy through the meta tag only and does not also send a Content-Security-Policy response header — the two would otherwise be enforced cumulatively by the browser, silently tightening (and potentially breaking) the policy the user already fully controls through the tag. Setting both that tag and a security.csp string is a build error instead of silently choosing one. Use the browser console's CSP violation to identify a blocked source, then add only that source or move the resource to the app origin.

When migrating an existing app, move production inline scripts into external modules. Remote scripts and frames are blocked by default and must be enabled with an explicit override. HTTPS/WSS backends work with the default; plain-HTTP remote backends need an override (same-origin loopback calls remain allowed).

Some directives only ever take effect through the response header — a browser ignores frame-ancestors and sandbox in a <meta> tag, and report-to/ report-uri need a real HTTP response to attach a reporting endpoint to. Murasaki therefore strips those directives out of the meta variant automatically (some engines log console noise for a directive they ignore) and keeps them only in the header, so the meta tag stays spec-clean while the header carries the full policy. frame-ancestors 'none' is part of the default policy for exactly this reason: it is enforced by the header, in both dev and packaged apps. The meta tag remains as a fallback for the directives it can enforce, useful for tooling that inspects the built file:// output directly rather than a live HTTP response. CSP also does not sanitize HTML or authorize Node functions, and the default inline-style allowance is a compatibility tradeoff rather than an XSS guarantee. Continue to avoid dangerouslySetInnerHTML, sanitize user-authored HTML, and pin third-party code.

Exposing a real network service

Murasaki API routes are app-local by design and are not reachable as a public service. A Main process can create TCP, WebSocket, or HTTP listeners using Node, but doing so creates a new security boundary outside Murasaki's window-authority protection.

  • Bind to 127.0.0.1 unless remote clients are an explicit product feature.
  • Authenticate before processing messages.
  • Define message and payload limits.
  • Rate-limit expensive operations.
  • Close the listener in shutdown().
  • Never reuse the Murasaki runtime token as your protocol credential.

In-page permission requests (getUserMedia, geolocation)

Renderer documents receive this response header in development and packaged apps:

Permissions-Policy: camera=(), microphone=(), geolocation=()

Those high-impact browser APIs therefore fail closed instead of falling through to WebKit/WebView2's platform-specific prompt behavior. This is separate from systemPermission:*, which manages host OS consent for native features. Wry 0.55 does not expose a cross-platform, per-window permission callback, so Murasaki does not currently offer a config escape hatch for renderer camera, microphone, or geolocation. Use a capability-checked native feature or a separately secured service until that boundary can be enforced consistently.

Signing and update keys are different

Code signing answers “who produced this executable?” to the operating system. The Ed25519 update signature answers “did the holder of this app's update key authorize this manifest?” to Murasaki. A production updater should use both: Developer ID/notarization or Murasaki's Windows Authenticode --sign flow for the OS artifact, plus Murasaki's manifest signature and payload hash.

Known security gaps

  • Native capabilities are per-window, with URL, filesystem-path, secure-storage-key, target-window, and OS-permission scopes. Dialog defaults, clipboard content, tray-menu content, elevated-process arguments, and other command arguments remain command-level; policies are config-owned rather than separately signed policy files.
  • Backend capabilities isolate Server Actions, 'use main', API/updater routes, events, and diagnostics per native window. Browser profiles are also isolated per window so same-origin Service Workers and shared browser state cannot cross that authority boundary. The primary retains the legacy profile; secondary persistence requires macOS 14+ (macOS 11–13 fails closed to a separate non-persistent store). XSS can still use every resource granted to its own window; handlers must enforce user/session/object authorization.
  • Incognito prevents persistent profile use but does not guarantee that multiple incognito windows share the same in-memory session.
  • Linux package signing (murasaki installer --sign) produces detached GPG signatures with no apt/dnf keyring or distro-repository trust integration. Windows Authenticode requires a developer-supplied certificate/Artifact Signing provider and Windows SDK SignTool.
  • Linux .deb packages install protocol/file-association MimeType entries through their .desktop file. Manually extracted AppDir/AppImage artifacts and Windows portable archives intentionally do not self-register.
  • Renderer native APIs are not callable directly from src/main.ts.

Track exact status on Platform & feature status.

Report a vulnerability

Do not open a public issue. Use a private GitHub Security Advisory or email [email protected]. The latest published minor is the supported pre-1.0 line.

Next

Improve this page on GitHub

On this page