Skill v1.2.0
currentAutomated scan98/100id: electron-security version: "1.2.0" title: "Electron Desktop Security" description: "Harden Electron apps: renderer trust boundary (nodeIntegration, contextIsolation, sandbox), contextBridge/IPC allowlists, shell.openExternal, navigation guards, deep-link auth, safeStorage" category: hardening severity: critical applies_to:
- "when generating Electron main-process code (BrowserWindow, ipcMain, app events)"
- "when generating a preload script or contextBridge surface"
- "when wiring custom-protocol / deep-link handlers"
- "when storing tokens or secrets in an Electron app"
- "when reviewing Electron IPC, navigation, or window configuration"
languages: ["javascript", "typescript"] token_budget: minimal: 1400 compact: 1900 full: 3300 rules_path: "checklists/" related_skills: ["frontend-security", "secret-detection", "auth-security", "container-security"] last_updated: "2026-06-20" sources:
- "Electron Security Checklist (official docs)"
- "OWASP — Electron / desktop application security"
- "CWE-78, CWE-22, CWE-250, CWE-693, CWE-829, CWE-1188"
- "Doyensec / Electronegativity research on Electron attack surface"
external_tools:
- name: electronegativity
purpose: "Electron app misconfiguration & anti-pattern scan" command: "electronegativity -i ."
Electron Desktop Security
Rules (for AI agents)
The unifying principle: treat the renderer as untrusted. Any renderer-side code execution (XSS in rendered content, a redirect, a deep link) must NOT be able to reach Node, the shell, the filesystem, or session tokens. Lock the renderer-trust boundary first; every IPC sink is reachable from a compromised renderer.
ALWAYS
- Configure every
BrowserWindowwithnodeIntegration: false,
contextIsolation: true, and sandbox: true. The preload + contextBridge is the supported way to give the renderer capabilities — the page never needs Node.
- Expose a minimal, typed API from the preload via
contextBridge.exposeInMainWorld.
Expose named functions only — never hand the renderer ipcRenderer, require, process, or whole modules.
- Validate every IPC argument in the main-process handler: type-check,
bound, and allowlist. The renderer is an attacker-controlled input source.
- Spawn child processes with
execFile/spawnand an argument array —
never exec with a shell string built from renderer input. Allowlist each argument (e.g. ^[A-Za-z0-9_-]+$).
- Confine filesystem paths:
path.resolve(base, input)then verify the result
startsWith(base + path.sep). Reject absolute paths and .. segments.
- Allowlist
shell.openExternaltohttps:(andmailto:if needed) after
parsing the URL. Reject file:, custom schemes, and anything else.
- Add navigation guards:
app.on('web-contents-created', …)with
contents.on('will-navigate', …) and contents.setWindowOpenHandler(…) that deny by default against a strict origin allowlist.
- Remember the contextBridge surface is exposed to **whatever origin the
webContents currently holds** — exposeInMainWorld does not re-check origin after a navigation. So a single missing will-navigate guard lets a remote / attacker origin inherit your entire IPC surface (this is how a stored hyperlink → navigation becomes 1-click RCE). The nav guard is the primary control; as defense-in-depth, gate the preload on location before exposing.
- Before attaching session tokens / cookies to an outbound request, verify the
target host is on your own-API allowlist. Never attach credentials to a renderer-supplied URL.
- Bind custom-protocol / deep-link auth to a one-time
state/ PKCE value the
app generated and is waiting for; validate before storing any token.
- Store tokens with Electron
safeStorage(OS keychain / DPAPI / libsecret),
not app-level crypto. Enable ASAR integrity + code signing for release.
- Treat every server the app connects to — backend, simulation / compute
node, auto-update channel, a multi-tenant cloud session — as potentially attacker-controlled (compromised, co-tenant, or MITM). Never load a server URL into a BrowserWindow / <webview> that carries your preload, and never feed a server response into an IPC sink (file path, shell arg, openExternal URL) without the same validation you apply to renderer input.
- Harden parsers that consume untrusted server / stream data (binary frames,
SDF / XML, model files): bound every length field before allocating, cap recursion and <include>-style expansion (circular refs → infinite loop / fetch), and wrap the parse in try/catch. Otherwise a malicious server crashes or hangs the renderer (DoS), even when memory-safety prevents RCE.
NEVER
- Set
nodeIntegration: true, disablecontextIsolation, disable
webSecurity, or set allowRunningInsecureContent: true — especially when the window loads remote or navigable content.
- Concatenate renderer input into a shell string
(child_process.exec(\docker kill ${names}\)) — command injection.
- Concatenate a renderer-supplied path for
fsread/write
(${BASE}${filePath}) — path traversal / arbitrary file write.
- Call
shell.openExternalon an arbitrary or renderer-controlled URL —
file: / custom protocol handlers are a local-launch / RCE vector.
- Attach
Authorization/ session cookies to a URL the renderer chose without
a host allowlist — XSS then exfiltrates the token.
- Accept a deep-link auth token (
myapp://auth?refresh-token=…) without
origin / state validation — login CSRF / session fixation.
- Encrypt tokens at rest with AES-CBC (no integrity) or a key derived solely
from a locally recoverable machine ID, and never keep a PLAINTEXT: fallback path.
- Ship a frameless / chromeless navigable window (
frame: false, no address
bar): after a redirect the user has no visual cue they left the app, so a phished navigation can silently clone your UI. Frameless is acceptable only behind a hard navigation guard.
- Assume the renderer (or its rendered content) is the only untrusted input —
a backend / sim / update server the app trusts can itself be compromised or, in multi-tenant deployments, driven by another tenant.
KNOWN FALSE POSITIVES
- Dev builds that load over
http://localhost:<port>with relaxed settings —
the rules apply to release builds; ensure dev config never ships.
- A custom protocol (
myapp://) for OAuth callbacks is expected — the control
is state/PKCE validation, not the scheme's existence.
contextBridge-exposed functions are intentional capabilities; review what
each one does (and whether it validates input), not the fact that the bridge exists.
shell.openExternalon a hard-codedhttps://constant (not user input) is
fine.
- A frameless window that loads only local first-party content (
file:///
packaged app) behind a deny-by-default nav guard is fine — the risk is frameless plus navigable to remote origins.
- Connecting to a backend and rendering its data (telemetry JSON, numbers,
binary frames) is normal desktop behaviour. The control is bounding / validating that data and never treating it as HTML, a filesystem path, or a shell argument — not avoiding the connection itself.
Context (for humans)
Electron ships a Chromium renderer and a Node main process in one app. The single most dangerous misconfiguration is nodeIntegration: true with no navigation guards: it turns any renderer-side bug (XSS in a report/telemetry view, an open redirect, a deep link) into full host RCE. Every IPC handler the preload exposes — file write, process exec, openExternal, token access — is then reachable by attacker-controlled renderer code.
AI assistants frequently generate Electron windows with nodeIntegration: true (it makes require "just work" in the page), child_process.exec with template-string interpolation, shell.openExternal(url) with no validation, and custom token crypto instead of safeStorage. This skill is the counterweight: keep the renderer sandboxed and every IPC sink validated.
The renderer is the first untrusted boundary, but not the only one: a desktop app also trusts every server it connects to. In multi-tenant or cloud-compute setups (a shared simulation backend, a per-session remote node), a co-tenant who compromises that server becomes an attacker feeding your privileged renderer — so server URLs must never load into a preload-bearing window, and server data is input to be validated, not trusted. The worst observed chain is the reverse of the obvious one: server-controlled content → a navigable preload window → electronAPI → local RCE.
Verify & lock (triaging a finding)
A scanner/review hit (electronegativity flag, a webPreferences line, a raw exec/openExternal call) is a candidate, not a confirmed bug. Confirm it, fix it, then lock it so it can't come back.
- Confirm it's real (probe / inspect). Load a test page in the release
build's renderer and try to reach Node: window.require, window.process, require('child_process').exec(...). Real if any resolve — nodeIntegration:true or contextIsolation:false/sandbox:false. For an IPC/sink hit, drive the exposed electronAPI function with hostile input (../../etc/passwd, a; id, file:///…, a foreign host for token attach) and watch whether the main process traverses/spawns/opens. For nav, attempt a will-navigate or window.open to an external origin and see if it lands. FP if isolation + sandbox are on and Node calls throw, the sink type-checks/allowlists and rejects, the nav guard denies by default, or it's dev-only config / a hard-coded https: constant / intentional myapp:// OAuth with state/PKCE.
- Fix, then lock with a regression test (unit or integration — dev's
call). Assert the locked boundary: renderer cannot reach require/Node; every BrowserWindow carries nodeIntegration:false, contextIsolation:true, sandbox:true, webSecurity:true; IPC handlers reject traversal/injection/ foreign-host args; the nav guard denies an external origin and setWindowOpenHandler returns deny; tokens go through safeStorage with no PLAINTEXT: fallback. Add one benign in-app action (valid IPC call, allowed https: open) that still works. Commit it so the guard can't be silently dropped.
References
checklists/electron_hardening.yaml— machine-readable hardening checks
(window config, IPC sinks, navigation, deep-link, token storage).