Skip to main content
Technology & EngineeringDesktop App426 lines

Security

Desktop application security including context isolation, preload scripts, CSP, and sandboxing for Electron and Tauri

Quick Summary18 lines
You are an expert in securing desktop applications, covering Electron's context isolation and sandboxing model, Tauri's permission system, Content Security Policy, and defense against common desktop app vulnerabilities.

## Key Points

1. **Context isolation**: Separates preload script context from renderer page context
2. **Sandbox**: Restricts renderer process capabilities at the OS level
3. **Preload scripts**: Controlled bridge exposing only necessary APIs
4. **Content Security Policy (CSP)**: Restricts what resources the renderer can load and execute
1. **Rust backend**: Memory-safe language eliminates entire classes of vulnerabilities
2. **Permission system**: Capability-based access control per window
3. **Command allowlisting**: Only explicitly registered commands are callable
4. **CSP**: Configurable policy for the webview
- Enable `contextIsolation: true`, `nodeIntegration: false`, and `sandbox: true` on every `BrowserWindow`. These are the foundation of Electron security.
- Never expose raw `ipcRenderer`, `require`, `process`, or `__dirname` to the renderer. Only expose specific, validated function calls.
- Validate every argument received in IPC handlers. Treat the renderer as an untrusted client.
- Implement path traversal protection on all file operations. Use `path.resolve()` and verify the result starts with an allowed directory.
skilldb get desktop-app-skills/SecurityFull skill: 426 lines
Paste into your CLAUDE.md or agent config

Desktop App Security — Desktop Apps

You are an expert in securing desktop applications, covering Electron's context isolation and sandboxing model, Tauri's permission system, Content Security Policy, and defense against common desktop app vulnerabilities.

Overview

Desktop applications face unique security challenges compared to web apps. They run with elevated privileges, have file system access, and can execute system commands. A vulnerability in a desktop app can lead to full system compromise. Both Electron and Tauri provide layered security models, but they must be correctly configured.

Electron's security layers:

  1. Context isolation: Separates preload script context from renderer page context
  2. Sandbox: Restricts renderer process capabilities at the OS level
  3. Preload scripts: Controlled bridge exposing only necessary APIs
  4. Content Security Policy (CSP): Restricts what resources the renderer can load and execute

Tauri's security layers:

  1. Rust backend: Memory-safe language eliminates entire classes of vulnerabilities
  2. Permission system: Capability-based access control per window
  3. Command allowlisting: Only explicitly registered commands are callable
  4. CSP: Configurable policy for the webview

Setup & Configuration

Electron: Secure BrowserWindow defaults

const mainWindow = new BrowserWindow({
  webPreferences: {
    // CRITICAL: Always enable these
    contextIsolation: true,       // Separate preload and page contexts
    nodeIntegration: false,       // No Node.js in renderer
    sandbox: true,                // OS-level sandboxing

    // Important security settings
    preload: path.join(__dirname, 'preload.js'),
    webSecurity: true,            // Enforce same-origin policy
    allowRunningInsecureContent: false,
    enableWebSQL: false,

    // Disable features you don't need
    webviewTag: false,            // Disable <webview> tag
    navigateOnDragDrop: false,    // Prevent file drag navigation
  },
});

Electron: Content Security Policy

<!-- index.html -->
<meta http-equiv="Content-Security-Policy" content="
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.yourapp.com;
  frame-src 'none';
  object-src 'none';
  base-uri 'self';
">
// Or set CSP via response headers in main process
const { session } = require('electron');

session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
  callback({
    responseHeaders: {
      ...details.responseHeaders,
      'Content-Security-Policy': [
        "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"
      ],
    },
  });
});

Tauri: Security configuration

// tauri.conf.json
{
  "app": {
    "security": {
      "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost",
      "freezePrototype": true,
      "dangerousDisableAssetCspModification": false
    }
  }
}

Tauri: Capability-based permissions

// src-tauri/capabilities/main-window.json
{
  "identifier": "main-window",
  "description": "Permissions for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "dialog:allow-open",
    "dialog:allow-save",
    {
      "identifier": "fs:allow-read",
      "allow": [{ "path": "$DOCUMENT/**" }]
    },
    {
      "identifier": "fs:allow-write",
      "allow": [{ "path": "$DOCUMENT/**" }]
    },
    {
      "identifier": "http:default",
      "allow": [{ "url": "https://api.yourapp.com/**" }]
    }
  ]
}
// src-tauri/capabilities/settings-window.json — more restricted
{
  "identifier": "settings-window",
  "windows": ["settings"],
  "permissions": [
    "core:default"
  ]
}

Core Patterns

Electron: Secure preload script

// preload.js — minimal, validated API surface
const { contextBridge, ipcRenderer } = require('electron');

// Only expose specific, validated operations
contextBridge.exposeInMainWorld('api', {
  // Typed operations instead of generic IPC
  readFile: (path) => ipcRenderer.invoke('secure:read-file', path),
  saveFile: (path, content) => ipcRenderer.invoke('secure:save-file', path, content),
  getSettings: () => ipcRenderer.invoke('secure:get-settings'),
  setSetting: (key, value) => ipcRenderer.invoke('secure:set-setting', key, value),

  // Event subscriptions with cleanup
  onUpdateAvailable: (callback) => {
    const handler = (_event, info) => callback(info);
    ipcRenderer.on('update:available', handler);
    return () => ipcRenderer.removeListener('update:available', handler);
  },
});

// NEVER do this:
// contextBridge.exposeInMainWorld('ipcRenderer', ipcRenderer);
// contextBridge.exposeInMainWorld('require', require);
// contextBridge.exposeInMainWorld('process', process);

Electron: Input validation in IPC handlers

// main.js — validate ALL inputs from renderer
const { ipcMain } = require('electron');
const path = require('node:path');
const fsp = require('node:fs/promises');

const ALLOWED_BASE_DIR = app.getPath('documents');

function isPathWithinAllowed(filePath, baseDir) {
  const resolved = path.resolve(filePath);
  return resolved.startsWith(path.resolve(baseDir) + path.sep);
}

ipcMain.handle('secure:read-file', async (_event, filePath) => {
  // Validate type
  if (typeof filePath !== 'string') {
    throw new Error('Invalid file path');
  }

  // Prevent path traversal
  if (!isPathWithinAllowed(filePath, ALLOWED_BASE_DIR)) {
    throw new Error('Access denied: path outside allowed directory');
  }

  // Validate extension
  const ext = path.extname(filePath).toLowerCase();
  const allowedExtensions = ['.txt', '.md', '.json', '.csv'];
  if (!allowedExtensions.includes(ext)) {
    throw new Error(`File type not allowed: ${ext}`);
  }

  return fsp.readFile(filePath, 'utf-8');
});

ipcMain.handle('secure:set-setting', async (_event, key, value) => {
  // Validate setting key against allowlist
  const allowedKeys = ['theme', 'language', 'fontSize', 'autoSave'];
  if (!allowedKeys.includes(key)) {
    throw new Error(`Unknown setting: ${key}`);
  }

  // Validate value types
  const validators = {
    theme: (v) => ['light', 'dark', 'system'].includes(v),
    language: (v) => /^[a-z]{2}(-[A-Z]{2})?$/.test(v),
    fontSize: (v) => typeof v === 'number' && v >= 8 && v <= 72,
    autoSave: (v) => typeof v === 'boolean',
  };

  if (!validators[key]?.(value)) {
    throw new Error(`Invalid value for setting ${key}`);
  }

  await updateSettings(key, value);
});

Electron: Navigation and new window restrictions

// Prevent navigation to untrusted URLs
mainWindow.webContents.on('will-navigate', (event, url) => {
  const parsedUrl = new URL(url);
  const allowedOrigins = ['http://localhost:3000', 'file:'];

  if (!allowedOrigins.some((origin) => url.startsWith(origin))) {
    event.preventDefault();
    console.warn('Blocked navigation to:', url);
  }
});

// Prevent new window creation (popups, window.open)
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
  // Open external links in the system browser
  if (url.startsWith('https://')) {
    require('electron').shell.openExternal(url);
  }
  return { action: 'deny' };
});

Electron: Secure protocol handlers

// Register a custom protocol for loading local resources
const { protocol } = require('electron');

protocol.handle('app', (request) => {
  const url = new URL(request.url);
  const filePath = path.join(__dirname, 'renderer', url.pathname);

  // Prevent path traversal via the protocol
  const resolved = path.resolve(filePath);
  if (!resolved.startsWith(path.resolve(__dirname, 'renderer'))) {
    return new Response('Forbidden', { status: 403 });
  }

  return net.fetch(`file://${resolved}`);
});

Protecting stored data

// Electron: Use safeStorage for encrypting sensitive data
const { safeStorage } = require('electron');

function encryptSecret(plaintext) {
  if (safeStorage.isEncryptionAvailable()) {
    const encrypted = safeStorage.encryptString(plaintext);
    return encrypted.toString('base64');
  }
  throw new Error('Encryption not available on this system');
}

function decryptSecret(encryptedBase64) {
  if (safeStorage.isEncryptionAvailable()) {
    const buffer = Buffer.from(encryptedBase64, 'base64');
    return safeStorage.decryptString(buffer);
  }
  throw new Error('Encryption not available on this system');
}

// Usage: storing an API key
const encrypted = encryptSecret('sk-my-secret-api-key');
store.set('apiKey', encrypted);

// Retrieving
const apiKey = decryptSecret(store.get('apiKey'));

Permission request handling

// Control which permissions the renderer can request
session.defaultSession.setPermissionRequestHandler(
  (webContents, permission, callback) => {
    const allowedPermissions = ['clipboard-read', 'notifications'];

    if (allowedPermissions.includes(permission)) {
      callback(true);
    } else {
      console.warn(`Denied permission request: ${permission}`);
      callback(false);
    }
  }
);

// Control which permission checks return true
session.defaultSession.setPermissionCheckHandler(
  (_webContents, permission) => {
    const allowedPermissions = ['clipboard-read', 'notifications'];
    return allowedPermissions.includes(permission);
  }
);

Tauri: Secure command design

use std::path::{Path, PathBuf};
use tauri::Manager;

// Validate paths in Rust commands
#[tauri::command]
async fn read_user_file(
    app: tauri::AppHandle,
    relative_path: String,
) -> Result<String, String> {
    // Construct path within allowed directory
    let docs_dir = app.path().document_dir()
        .map_err(|e| format!("Cannot resolve documents dir: {e}"))?;

    let full_path = docs_dir.join(&relative_path);

    // Prevent path traversal
    let canonical = full_path.canonicalize()
        .map_err(|e| format!("Invalid path: {e}"))?;

    if !canonical.starts_with(&docs_dir) {
        return Err("Access denied: path outside documents directory".into());
    }

    std::fs::read_to_string(&canonical)
        .map_err(|e| format!("Failed to read file: {e}"))
}

// Use strong types instead of raw strings
#[derive(serde::Deserialize)]
struct CreateItemRequest {
    name: String,
    description: String,
}

#[tauri::command]
async fn create_item(request: CreateItemRequest) -> Result<String, String> {
    // Validate input lengths
    if request.name.is_empty() || request.name.len() > 255 {
        return Err("Name must be 1-255 characters".into());
    }
    if request.description.len() > 10_000 {
        return Err("Description too long".into());
    }

    // Sanitize — strip control characters
    let name: String = request.name.chars()
        .filter(|c| !c.is_control())
        .collect();

    // Process the validated input
    Ok(format!("Created: {name}"))
}

Best Practices

  • Enable contextIsolation: true, nodeIntegration: false, and sandbox: true on every BrowserWindow. These are the foundation of Electron security.
  • Never expose raw ipcRenderer, require, process, or __dirname to the renderer. Only expose specific, validated function calls.
  • Validate every argument received in IPC handlers. Treat the renderer as an untrusted client.
  • Implement path traversal protection on all file operations. Use path.resolve() and verify the result starts with an allowed directory.
  • Set a strict Content Security Policy that blocks eval(), inline scripts, and unauthorized remote resources.
  • Block navigation to untrusted URLs and prevent new window creation from the renderer.
  • Use safeStorage (Electron) or OS keychain libraries to encrypt stored secrets. Never store API keys or tokens in plaintext config files.
  • In Tauri, define the narrowest possible permissions per window. A settings window should not have file system access.
  • Regularly update Electron and Tauri to get Chromium/WebKit security patches.

Common Pitfalls

  • Disabling contextIsolation for convenience: This gives the renderer page direct access to Node.js APIs through the preload. Any XSS vulnerability becomes a full system compromise.
  • Using shell.openExternal with unvalidated URLs: An attacker-controlled URL passed to shell.openExternal can execute arbitrary commands on some platforms. Always validate the URL scheme.
  • Storing secrets in localStorage or plain JSON files: These are trivially readable. Use OS-provided encryption (safeStorage, Keychain, DPAPI).
  • Loading remote content without CSP: If your app loads any remote content, an attacker who compromises that origin gains access to all exposed APIs.
  • Ignoring the will-navigate event: Without navigation restrictions, an attacker can navigate the renderer to a malicious page that still has access to your preload APIs.
  • Over-permissive Tauri capabilities: Granting fs:allow-read without a scope restriction gives access to the entire file system.
  • Not updating dependencies: Electron bundles Chromium, which receives frequent security patches. Running an outdated Electron version exposes users to known browser vulnerabilities.
  • Prototype pollution: In Electron, set contextIsolation: true to prevent page scripts from modifying Object.prototype and affecting the preload. In Tauri, enable freezePrototype: true.

Core Philosophy

Desktop application security operates on a fundamentally different threat model than web security. A vulnerability in a web application might leak data or deface a page. A vulnerability in a desktop application can install malware, exfiltrate files, or compromise the entire operating system. The renderer process is a browser window with potential access to the full filesystem and process execution — every security control must be designed with this gravity in mind.

Defense in depth is the only viable strategy. No single control is sufficient. Context isolation prevents the page from accessing Node.js APIs. Sandboxing restricts the renderer at the OS level. Preload scripts expose only specific, validated functions. Content Security Policy limits what resources can load. Input validation catches malicious data. Each layer catches what the others miss.

Treat the renderer as untrusted, always. Even if you only load local HTML files, a future feature might load remote content, render user-supplied HTML, or integrate a third-party widget. Building every IPC handler with input validation, path traversal protection, and type checking from day one means you are protected when the attack surface inevitably expands.

Anti-Patterns

  • Disabling contextIsolation for convenience — this gives the renderer page direct access to Node.js APIs through the preload; any XSS vulnerability becomes a full system compromise.

  • Exposing require, process, or ipcRenderer directly — passing these objects through contextBridge to the renderer gives the page unlimited access to the system; only expose specific, validated function wrappers.

  • Storing secrets in plaintext configuration files — API keys, tokens, and passwords in JSON config files are trivially readable; use safeStorage (Electron) or OS keychain integration for encrypted storage.

  • Accepting file paths from the renderer without validation — a renderer-supplied path like ../../../etc/passwd can escape allowed directories; always resolve paths and verify they start with the allowed base directory.

  • Not restricting navigation and window creation — without will-navigate and setWindowOpenHandler restrictions, a compromised renderer can navigate to a malicious page that retains access to all preload APIs.

Install this skill directly: skilldb add desktop-app-skills

Get CLI access →