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
| Boundary | Built-in protection | Your responsibility |
|---|---|---|
| Remote URL → app WebView | Exact app-origin navigation stays in-window; off-origin HTTP(S) opens in the system browser; file:, data:, and javascript: navigations are denied | Do not weaken navigation behavior or inject remote scripts into the renderer |
| Renderer → Rust native commands | Exact-origin IPC check and a typed, deny-by-default allowlist evaluated for the calling window | Grant only the commands that renderer uses; validate app-level intent before broad operations such as window:manage |
| Renderer → local Node | Privileged /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 validated | Grant 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 Main | Only configured schemes/extensions are normalized into openRequested() targets; delivery uses the authenticated app-local channel | Treat every URL and path as attacker-controlled; validate intent, identifiers, hosts, and file contents before acting |
| App → update host | Ed25519 signature over raw manifest bytes, then SHA-256 verification of the selected payload | Protect the private update key and publish hashes/signatures together |
| OS → installed app | macOS Developer ID/notarization and Windows Authenticode orchestration sign and verify final app-owned artifacts | Supply 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
hostor 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
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:
| Permission | Scope field |
|---|---|
shell:openExternal, webview:readCookies, webview:writeCookies | urls with exact URLs or an HTTP(S) trailing /** subtree |
shell:showItemInFolder | absolute, non-traversing paths, exact or with trailing /** |
window:open, window:manage | exact declared windows labels |
systemPermission:status, systemPermission:request | exact permissions names |
secureStorage:get, secureStorage:set, secureStorage:delete | exact 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:
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.
'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.
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.1unless 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
.debpackages install protocol/file-associationMimeTypeentries through their.desktopfile. 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.