Dev.to Security 🔐 Cybersecurity 👁 0 📖 9 min read

An Electron Preload Script Is a Security Boundary, Not a Convenience Layer

A ticket comes in. The support team wants the desktop app to open a vendor's dashboard inside a tab instead of kicking the user out to the browser. The dashboard is a normal web page on a domain nobody on the team contro

A ticket comes in. The support team wants the desktop app to open a vendor's dashboard inside a tab instead of kicking the user out to the browser. The dashboard is a normal web page on a domain nobody on the team controls. The change is small: point a window at a URL, ship it.

That ticket is where most Electron security incidents start. Not at a line of code that looks dangerous, but at a product decision that quietly moves untrusted content inside a process that can read the user's disk.

The useful way to think about an Electron app is that the preload script is a security boundary, in the same sense that an HTTP handler is a security boundary. Everything on the far side of it is hostile until proven otherwise, and the job of the code at the boundary is to decide what crosses.

The shape of the mistake

Here is the preload that ships. It is three lines and it reviews fine, because it looks like plumbing.

// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('api', {
  send: ipcRenderer.send,
  invoke: ipcRenderer.invoke
})

The Electron documentation calls this out directly. It "directly exposes a powerful API without any kind of argument filtering", and the consequence is stated just as plainly: "This would allow any website to send arbitrary IPC messages, which you do not want to be possible."

The developer who wrote it was not careless. They were building a generic bridge so the renderer team would not have to ask for a new channel every week. That is a reasonable engineering instinct and it is exactly the instinct that has to be resisted here, because a generic bridge is a generic bridge for the attacker too. Every IPC handler in the main process, including the one somebody adds next quarter to write a file, is now reachable from whatever page is loaded.

The documented rule is the opposite shape: "You should not directly expose Electron's APIs, especially IPC, to untrusted web content in your preload scripts."

The defaults are already on your side

Three of the settings that used to cause most of the damage are now defaults, and it is worth knowing which Electron version made each one a default, because the dangerous pattern in a real codebase is usually not a setting that was never turned on. It is a setting that was explicitly turned off in 2021 to make something work, with a comment that nobody has revisited since.

Setting Secure state Default since
contextIsolation enabled Electron 12.0.0
nodeIntegration for remote content disabled Electron 5.0.0
sandbox enabled Electron 20.0.0

So the audit question is not "did we enable context isolation". It is "does anything in this repository set these to the insecure value", which is a grep, and it should be a test.

// A test that fails the build, not a comment in a config file.
const DANGEROUS = ['nodeIntegration', 'webSecurity', 'allowRunningInsecureContent',
                   'experimentalFeatures', 'enableBlinkFeatures', 'webviewTag']

test('no window disables a security default', () => {
  for (const file of browserWindowSources()) {
    for (const key of DANGEROUS) {
      expect(file.text).not.toMatch(new RegExp(`${key}\\s*:\\s*true`))
    }
    expect(file.text).not.toMatch(/contextIsolation\s*:\s*false/)
    expect(file.text).not.toMatch(/sandbox\s*:\s*false/)
  }
})

A grep-based test is crude and it is still the single highest-value test in an Electron codebase, because the failure it catches is a one-word diff in a file that reviewers skim.

The documentation is unambiguous about the worst combination: "Under no circumstances should you load and execute remote code with Node.js integration enabled."

Rule 1: expose verbs, not transports

The bridge should describe what the application can do, not how messages travel. The documented pattern is one narrow method per message.

// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('myAPI', {
  loadPreferences: () => ipcRenderer.invoke('load-prefs')
})

Read that against the earlier version. api.invoke is an open door whose shape depends on every handler registered anywhere in the main process. myAPI.loadPreferences is a door with one thing behind it, and the set of things a compromised renderer can ask for is now a list you can print out and review.

The practical test for any method you are about to expose is to assume the renderer is already hostile and ask what the worst call looks like. readFile(path) fails that test immediately. loadPreferences() passes, because the path is chosen by the main process and the renderer cannot influence it.

One implementation note that costs people an afternoon: the bridge serialises what crosses it, and the documentation warns that "you can't send custom prototypes or symbols over the bridge". Class instances arrive as plain objects with their methods missing. Design the bridge to pass data, not behaviour.

Rule 2: the main process validates, every time

Argument filtering in the preload is a courtesy. It runs in a context the renderer cannot reach under context isolation, which is good, but the handler in the main process is the thing that actually holds the privilege, so that is where the check belongs.

// main.js
const path = require('node:path')

ipcMain.handle('open-project', async (event, name) => {
  if (!isTrustedSender(event)) throw new Error('rejected sender')
  if (typeof name !== 'string' || !/^[a-z0-9-]{1,64}$/.test(name)) {
    throw new Error('invalid project name')
  }
  const file = path.join(PROJECT_ROOT, `${name}.json`)
  // name cannot contain a separator or a dot, so this cannot escape PROJECT_ROOT
  return readProject(file)
})

Two things are happening there and both are on the checklist. The allow-list regular expression is validation. isTrustedSender is the item the documentation lists as "Validate the sender of all IPC messages", and it exists because a handler does not know which frame called it unless it asks. An application that loads any remote content, in a window or a <webview>, has frames in it that should never be able to reach an application handler at all.

Validate by comparing the sender's origin against what you expect, and fail closed when it does not match. Do not infer trust from the channel name. Channel names are strings and the renderer picks them.

Rule 3: remote content gets its own renderer, or no renderer at all

Back to the ticket at the top. The vendor dashboard has to appear somewhere. The documented framing is the one to argue from: "A security issue exists whenever you receive code from an untrusted source (e.g. a remote server) and execute it locally."

The options are not equal.

  • A BrowserWindow with no preload at all, sandboxed, node integration off, loading only that origin. The page gets no bridge, so there is nothing for it to call.
  • A <webview> tag, which the docs suggest controlling from the main process, without allowpopups, with its options verified rather than accepted from markup.
  • An external browser via shell.openExternal, which the checklist warns against using with untrusted content, meaning the URL has to come from your code and not from the page.

The option that keeps getting chosen because it is one line is the one where the vendor page is loaded into a window that already has the application's preload attached. That window now exposes your bridge to a third party's JavaScript, and to whatever their advertising script loaded this morning.

If the dashboard genuinely needs to talk to the application, the direction of the dependency should invert: the main process fetches what it needs from the vendor's API and renders it in your own page, and the untrusted markup never executes with any privilege at all.

Rule 4: permissions are granted unless you say otherwise

This one surprises people, and it is documented: "By default, Electron will automatically approve all permission requests" unless a custom handler is configured. Camera, microphone, geolocation, notifications. A browser asks the user. An Electron app, by default, does not ask anybody.

// main.js
const ALLOWED = new Set(['clipboard-read'])

session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
  const origin = new URL(webContents.getURL()).origin
  callback(origin === APP_ORIGIN && ALLOWED.has(permission))
})

The checklist says to use ses.setPermissionRequestHandler() in all sessions that load remote content. The word that matters is "all". A second session created for a vendor window does not inherit a handler you installed on the default session.

Rule 5: navigation is the escape hatch everyone forgets

An application window that can navigate anywhere is an application window that can navigate to anywhere an attacker wants, including a page that then sits inside your preload. A single open redirect on a domain you trust is enough to get there, and open redirects are common enough that they are frequently not even treated as bugs.

app.on('web-contents-created', (_event, contents) => {
  contents.on('will-navigate', (event, url) => {
    if (new URL(url).origin !== APP_ORIGIN) event.preventDefault()
  })
  contents.setWindowOpenHandler(({ url }) => {
    // open externally after your own check, never automatically
    return { action: 'deny' }
  })
})

Both of these are checklist items: "Disable or limit navigation" and "Disable or limit creation of new windows". They are cheap, they are global, and they survive the next developer who adds a window without reading this page.

Rule 6: the transport-level settings

Three more items from the list, grouped because they share a reason.

"Only load secure content" rules out plain HTTP for anything the app depends on, including the first load during development, because a development habit becomes a production configuration more often than anyone admits.

"Do not disable webSecurity" is usually broken for a local development reason, typically a cross-origin request to an API on another port. The honest fix is a proxy or correct CORS headers on the API. Disabling same-origin policy in a process that also has a bridge to the file system is not a development convenience, it is a different application.

"Do not enable allowRunningInsecureContent", which the docs describe as disabling the protection against mixed content, and a restrictive Content-Security-Policy, which the docs suggest starting at script-src 'self'. A CSP in an Electron app is doing the same job it does on the web, with the difference that the page it protects is a page with native capability behind it.

Why this passes code review

Every item above is a small diff. contextIsolation: false is one word. Adding invoke to the bridge is one line that removes work for three other people. Pointing a window at a vendor URL is a product improvement with a ticket number attached.

None of them reads as a security change, which is why the checks have to be mechanical rather than cultural. A grep test in CI, a sender check in every handler, and a written rule that a new BrowserWindow needs a reviewer who knows what the preload exposes, will catch more than a security review scheduled for the quarter.

The ship checklist

  1. Nothing in the repository sets nodeIntegration, webSecurity, allowRunningInsecureContent, experimentalFeatures, enableBlinkFeatures or webviewTag to true, and nothing sets contextIsolation or sandbox to false, enforced by a test.
  2. The preload exposes named operations only. No ipcRenderer, no send, no invoke, no require.
  3. Every exposed method is safe to call with attacker-chosen arguments, or takes no arguments at all.
  4. Every ipcMain handler validates its arguments against an allow-list and rejects unexpected types.
  5. Every ipcMain handler validates the sender and fails closed.
  6. Remote content loads in a window or session with no application preload attached.
  7. Every session that loads remote content has a permission request handler, and the default is deny.
  8. will-navigate is restricted to the application origin, and setWindowOpenHandler denies by default.
  9. shell.openExternal is never called with a URL that came from page content.
  10. A Content-Security-Policy is set and starts from script-src 'self'.
  11. The Electron version is current, and the fuses that can be flipped for the build have been reviewed.

The underlying point

Electron gets described as a browser with file system access, and that framing is what leads teams astray, because it suggests the risk is spread evenly across the app. It is not. It is concentrated at one file. Everything the renderer can reach, it reaches through the preload, and the size of your attack surface is literally the number of things listed in exposeInMainWorld.

Keep that list short enough to read aloud in a review, and most of the rest of the checklist becomes defence in depth rather than the only thing standing between a vendor's advertising script and a user's documents folder.

INNERLUXES builds Chromium-based and Electron desktop applications, and this checklist is the one the team runs before a desktop build ships.

Sources: Electron documentation, Security and Context Isolation.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.