---
title: "Security Considerations"
vignette: >
  %\VignetteIndexEntry{Security Considerations}
  %\VignetteEngine{quarto::html}
  %\VignetteEncoding{UTF-8}
---

```{r}
#| include: false
library(shinyelectron)

# Read the default webPreferences block from the main.js template so this
# article stays in sync with the source. The template uses {{app_slug}} as a
# handlebars placeholder; we render it as <app_slug> for readability.
read_web_preferences <- function() {
  path <- system.file("electron/shared/main.js", package = "shinyelectron")
  lines <- readLines(path)
  start <- grep("^\\s*webPreferences:\\s*\\{\\s*$", lines)[1]
  indent <- regmatches(lines[start], regexpr("^\\s*", lines[start]))
  close_pattern <- paste0("^", indent, "\\},?\\s*$")
  end <- start + grep(close_pattern, lines[(start + 1):length(lines)])[1]
  block <- lines[start:end]
  block <- sub(paste0("^", indent), "", block)
  block <- gsub("\\{\\{app_slug\\}\\}", "<app_slug>", block)
  block[length(block)] <- sub(",\\s*$", "", block[length(block)])
  block
}
```

When you turn a Shiny app into a desktop app, it gains a lot of power. On the web, the browser and a server fence in what your app can do. On someone's laptop, your app can do anything they can: read their files, talk to the network, run programs. That power is what makes a desktop app useful, and it is also why a desktop app needs more security thought than a web one.

Three places things can go wrong, and the rest of this guide walks through each in order:

1. **The Electron window that shows your app.** Without care, it can be tricked into running anything. shinyelectron sets safe defaults; your job is to leave them alone.
2. **Your Shiny code.** Whatever your app can read or run, a determined user can probably get it to read or run something else. A few habits cover most of it.
3. **How the app reaches your users.** Unsigned downloads, plain-HTTP update channels, and passwords baked into the bundle are the classic ways a release goes wrong.

Two related topics live in their own guides: [code signing](code-signing.html) and [auto-updates](auto-updates.html), which rely on signed builds to work.

## Two processes, one trust boundary

An Electron app has two process types:

- **Main process.** Node.js with full OS access. Creates windows, drives the lifecycle, spawns R, Python, or a container.
- **Renderer process.** Chromium displaying your Shiny UI. Treat it as hostile territory.

The rule is unconditional: **the renderer never touches Node.js directly**. With `nodeIntegration: true`, any script in the renderer, including one injected through an XSS bug or a transitive HTML widget, runs arbitrary OS commands. The defense is to give the renderer the narrowest possible gate to the main process, and to keep that gate auditable.

<figure><picture><source srcset="../man/figures/security-trust-boundary-dark.svg" media="(prefers-color-scheme: dark)" /><img src="../man/figures/security-trust-boundary.svg" alt="Diagram of the Electron trust boundary. On the left, the renderer process hosts Chromium with the Shiny UI, treated as an untrusted zone with nodeIntegration false, contextIsolation true, sandbox true, and webSecurity true. In the middle, a preload script using contextBridge exposes only a narrow IPC surface: lifecycle.onStatus, lifecycle.retry, and lifecycle.quit. On the right, the trusted main process runs Node.js with full OS access and spawns R via shiny runApp, Python via shiny run, container backends via docker run, or the Express server for Shinylive." /></picture><figcaption>Electron's trust boundary: the renderer runs untrusted web content, the preload script exposes a small IPC surface, the main process holds full OS access.</figcaption></figure>

The full picture lives in the [Electron Security Checklist](https://www.electronjs.org/docs/latest/tutorial/security). The rest of this section explains how shinyelectron implements each item.

## What shinyelectron locks down

The generated `BrowserWindow` ships with the safe choice for every webPreferences flag that has one. The block below is read live from the template at `inst/electron/shared/main.js`:

```{r}
#| echo: false
#| results: asis
cat(c("```js", read_web_preferences(), "```"), sep = "\n")
```

Each setting, in one line:

| Setting | Value | What it stops |
|---------|-------|---------------|
| `nodeIntegration` | `false` | Renderer cannot call `require()` or any Node API |
| `contextIsolation` | `true` | Page scripts cannot reach into the preload's scope |
| `sandbox` | `true` | Renderer runs inside the Chromium OS sandbox |
| `enableRemoteModule` | `false` | Disables the deprecated `remote` module entirely |
| `webSecurity` | `true` | Same-origin policy stays on |
| `partition` | per-app | Each shinyelectron app gets its own session storage |

**The preload script is the only bridge.** It exposes a short list of named IPC methods (`lifecycle.onStatus`, `lifecycle.retry`, `lifecycle.quit`, and a few peers) through `contextBridge.exposeInMainWorld()`. The renderer can call those and nothing else: it never sees `ipcRenderer`, never imports a Node module.

**DevTools default to off.** The DevTools menu item only appears when `menu.show_dev_tools` is `true` in `_shinyelectron.yml` (the default is `false`). Leave it that way for production. DevTools lets the user, or anything injected into the page, execute JavaScript and inspect any value the app holds.

## Content-Security-Policy scope

The lifecycle window (`lifecycle.html`) and the multi-app launcher (`launcher.html`) each carry a `Content-Security-Policy` meta tag that restricts sources to `'self'` with `'unsafe-inline'` permitted for styles and scripts. This covers the startup, error, and shutdown phases shown before the Shiny app is ready.

The Shiny application itself is loaded by navigating to a localhost URL (`http://localhost:<port>`). No CSP is injected for that content by the Electron shell; the CSP that applies to the running Shiny app is whatever the Shiny server sends in its own HTTP headers, which is none by default. Keep this in mind if the app loads any external scripts or embeds third-party widgets: the Electron `webSecurity: true` setting enforces the same-origin policy, but it does not substitute for a CSP on the app content.

## Cross-origin headers (Shinylive only)

Shinylive runs WebR or Pyodide in the browser, which needs `SharedArrayBuffer`, which needs cross-origin isolation. The local Express server attaches the right headers automatically:

```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: cross-origin
```

Native R and Python apps load over plain `localhost` and need no extra cross-origin header work: the content originates from a process you started, not a third party.

**Rule of thumb.** Avoid external scripts (CDNs, third-party widgets) unless the app genuinely needs them. Each external resource is a trust dependency you cannot audit. Bundle local copies when you can.

## Runtime download integrity

Every runtime archive is verified against a SHA-256 checksum published by its upstream before extraction. A mismatch aborts the install with the expected and actual hashes.

- **Node.js** (`install_nodejs()`): verifies against `SHASUMS256.txt` from nodejs.org.
- **Portable R** (`install_r_portable()`): verifies against the per-asset `.sha256` sidecar published next to each portable-r release archive.
- **Portable Python** (`install_python_standalone()`): verifies against the `SHA256SUMS` file published with each python-build-standalone release.

If the published checksum cannot be fetched (for example, a transient network error), the installer prints a warning and continues without verification rather than failing the build, mirroring the Node.js behavior. To require verification, confirm the checksum file is reachable before building.

The `auto-download` strategy carries the same protection to the end user's machine: the runtime manifest embedded in the app records the expected SHA-256 (when available at build time), and the Electron runtime-downloader verifies the archive it fetches on first launch before extracting it.

## Where your app still has to think

shinyelectron secures the shell. Inside the shell, your Shiny code runs with the same OS permissions as the user who launched it. That is the whole point of a desktop app, and it is also the part you have to defend yourself.

### Inputs are user-controlled

Every reactive input arrives from the renderer. A determined user can hand-craft any value, regardless of what the UI suggests. Two rules cover most of it.

**Validate at the boundary.** Check types, ranges, and shape before you act on a value, not after. Shiny's `req()` and `validate(need(...))` exist for this; use them.

**Never build shell commands by string concatenation.** Pass the program and its arguments as separate values so the shell never sees a chance to parse them. The pattern looks slightly different in each language but the rule is the same.

**R.** `system()` and `system2()` (when given a single command string) hand the assembled text to `/bin/sh -c`, where metacharacters like `;`, `|`, and `$` are interpreted. Use `processx::run()` instead, or `system2()` with the arguments as a character vector, so the program and each argument reach the OS as separate strings:

```r
# Don't: paste() builds one string that the shell then parses
system(paste("convert", input$file, "out.png"))

# Do: program and args cross to the OS as separate values
processx::run("convert", c(input$file, "out.png"))
```

**Python.** `subprocess.run()` defaults to `shell=False`, which is the safe form. The list form passes the program and its arguments straight to the OS process API. Setting `shell=True` (or calling `os.system()`) routes through `/bin/sh` and re-introduces the parsing problem:

```python
# Don't: shell=True hands the f-string to /bin/sh -c
subprocess.run(f"convert {input.file} out.png", shell=True)

# Do: list form skips the shell entirely
subprocess.run(["convert", input.file, "out.png"], check=True)
```

In both cases, the difference is whether `; rm -rf ~` (or any other crafted value the renderer hands you) is treated as data or as a command.

### File access is user-wide

`file.choose()`, `readLines()`, Python's `open()`: they inherit the launching user's permissions and can reach anything that user can reach. SSH keys, browser history, the Documents folder. Expected for a desktop app, worth remembering when porting from a sandboxed Shiny Server where these calls quietly fail.

**Least privilege still applies.** If the app does not need to write files or spawn processes, do not include code that can. Removed code cannot be exploited.

### Secrets do not belong in the bundle

Anything you copy into the build is recoverable from the installed app. `.asar` is not encryption.

```
# These should NEVER be in your app directory
.env
.Renviron
credentials.json
service-account-key.json
```

Add them to `.gitignore` and check that your build pipeline does not sweep them in. When the app needs an API key at runtime, the options are:

- **An environment variable** the user sets on their machine.
- **The OS keychain** via [keyring](https://cran.r-project.org/package=keyring): free, encrypted, OS-managed.
- **A first-launch prompt** that stores an encrypted credential in the app's user-data directory.

## Strategy-specific notes

The [runtime strategy](runtime-strategies.html) you pick decides which sandbox actually contains your code.

### Shinylive

The strongest isolation of any strategy. WebR or Pyodide run inside the browser's WebAssembly sandbox, which has no syscalls of its own and no filesystem outside an in-memory shim. Shell injection and arbitrary file access are physically not on the menu. The trust boundary is the same as any other web page, plus the Electron defaults above.

### System, bundled, auto-download

A real R or Python child process. Whatever is reachable from `system()` or `subprocess` is reachable from the app. The defense is the input-validation discipline from the previous section. None of these strategies adds additional sandboxing.

### Container

Docker or Podman puts the app inside an OS-level container, which is a stronger boundary than process isolation but weaker than a virtual machine:

- **The app directory is mounted at `/app` inside the container** by the container backend, read–write. Anything under `container.volumes` is mounted explicitly on top of that.
- **Inbound network reaches the container only on the published port.** The container can still make outbound connections by default. Add a custom Docker network if you need to block them.
- **Default images run as root inside the container.** shinyelectron's bundled Dockerfiles do not set a `USER` directive, and neither do their parent images. For trusted internal apps that is usually fine; otherwise override `USER` in a custom Dockerfile.
- **Docker's daemon runs as root on Linux.** A container escape is a host root escape. Podman defaults to rootless mode and is the safer choice when available.

Containers buy isolation, not invulnerability. Mount only what you must, keep the engine patched, and treat the container's filesystem as a useful constraint rather than a guarantee.

## Distribution

Two release-time concerns. Each has its own vignette; the rules of thumb live here.

**Sign your production builds.** Unsigned macOS apps trip Gatekeeper, unsigned Windows installers trip SmartScreen, and unsigned anything cannot use `electron-updater`. See [Code Signing and Distribution](code-signing.html).

**Serve update manifests over HTTPS.** `electron-updater` verifies the signature on each downloaded artifact, but the manifest that points it at the artifact must reach the user untampered. Plain HTTP lets an attacker swap that manifest. See [Auto Updates](auto-updates.html).

## What not to do

shinyelectron's defaults block every common Electron footgun. You can re-enable any of them by editing the generated files. Do not.

::: {.callout-warning}
### Do not modify these settings in the generated Electron code

- **`nodeIntegration: true`**: any script in the renderer, including anything an XSS bug injects, gets full Node.js access.
- **`contextIsolation: false`**: page scripts can then reach into the preload scope, and the boundary is gone.
- **`sandbox: false`**: the renderer leaves the Chromium OS sandbox.
- **`webSecurity: false`**: same-origin policy goes off, and arbitrary pages can call arbitrary origins.
- **Loading remote URLs in the main window.** shinyelectron loads `localhost` (native backends) or local files (lifecycle pages). An external URL runs untrusted code with your app's Electron privileges.
- **Shipping with `menu.show_dev_tools: true`.** DevTools lets anyone, or anything, inspect values, run JavaScript, and dig toward the main process.
:::

## Summary

| Layer | Owned by | What to do |
|-------|----------|------------|
| Electron shell | shinyelectron | Defaults are safe; do not edit them |
| Cross-origin headers | shinyelectron | Set automatically for Shinylive |
| Shiny app code | You | Validate inputs, parameterize commands, avoid shell strings |
| Filesystem use | You | Least privilege; the code you remove cannot be exploited |
| Credentials | You | Never bundle; use env vars or the OS keychain |
| Container image | You | Override `USER`, mount minimum, keep engine patched |
| Code signing | You | Sign release builds; HTTPS for update manifests |

Further reading: the [Electron Security documentation](https://www.electronjs.org/docs/latest/tutorial/security).
