Stop untrusted pages from using an Electron bridge
Node.js 22.12 or newer and npm. Electron 44.7.0 and playwright-core 1.64.0, pinned in
package-lock.json. The Electron tests open hidden windows, so they need a desktop session
(macOS or Windows, or Linux with a display; see Dev container for Linux without one).

Reference solution: not included here, so the exercise does not hand you the answer.
Attempt the repair first. Then download electron-ipc-bridge-solution.zip from the project card
on https://softwaresecurity.com/labs/#project-electron-ipc-bridge and extract it into this
folder; it adds reference.mjs and reference.preload.cjs without changing your files, and the
reference commands below need them.

Outcome
A small notes app shows note text that other people wrote. Repair its security policy so that
no page content (a link in a note, a page the window ends up showing, or script injected into
the app's own page) can use the preload bridge to write files, open programs or reach Node.js.
The fix is the one Electron's security checklist describes: context isolation and the sandbox,
a narrow contextBridge API, a sender and payload check in every IPC handler, navigation and
window limits, a Content Security Policy and denied permissions.

Prerequisites and time
Know JavaScript modules, the DOM and what an Electron main process, renderer and preload are.
Allow 60 to 90 minutes.

Files and trust boundary
  index.html, renderer.mjs  the app's page, served from app://bundle/ by a custom protocol.
                            Trusted code showing untrusted data (note text, links). Shared.
  exercise.preload.cjs      runs before EVERY page the window loads and publishes window.notes.
                            What you repair, with exercise.mjs.
  main.mjs                  the main process: windows, the app:// protocol, files and links.
                            It applies the policy in exercise.mjs. Shared; do not edit.
  exercise.mjs              the policy: webPreferences, CSP header, sender check, payload
                            checks, IPC handlers, navigation and window rules, permissions.
  notes-store.mjs           the notes folder on disk. Shared.
  exercise.test.mjs         24 fast tests of the policy and preload in plain Node.js.
  e2e.test.mjs              9 tests of the real app in Electron, driven by Playwright.

  page content --window.notes--> preload --named IPC channel--> handler in the main process
  (untrusted)                     (narrows)                       (checks sender, then payload)
                                                                   --> notes folder, browser
The preload narrows what a page can ask for; only the main process can refuse. Assume any page
the window shows can call whatever the preload exposes.

1. Install
   npm ci --ignore-scripts
   npx install-electron --no
   The first command installs the pinned packages without running package scripts. Since
   Electron 42 the electron package downloads its binary on first use instead of in a
   postinstall script; the second command does that now. It fetches Electron 44.7.0 for your
   platform from GitHub (about 130 MB for macOS on Apple silicon) and checks it against the
   SHA-256 sums in the package's checksums.json. playwright-core downloads no browsers.

2. Look at the app
   npm start
   Notes live in a notes folder in the app's user data directory (set NOTES_DIR to use
   another). Click the link in the welcome note: in the starter, the app window itself navigates
   to electronjs.org, and that site's page receives window.notes, including notes.invoke. Close
   the app.

3. Run the fast tests
   npm test
   Expect 5 passing and 19 failing in the unmodified starter. The failures name the rule each
   check enforces: trusted senders, note ids, links, the preload's API, the IPC channels,
   navigation, windows, permissions, webPreferences and the CSP.

4. Run the exploit tests in Electron
   npm run test:e2e
   Expect 1 passing and 8 failing in the starter. Each test starts the app with hidden windows,
   a fresh notes folder and a canary path outside it, and serves an untrusted page from
   127.0.0.1. When that page loads, it calls notes.invoke('file:write', canary) and
   notes.saveNote(), and reports back. In the starter, a click on a link in a note takes the app
   window to that page, which writes the canary file; window.open gives it a new app window with
   the bridge (briefly visible on screen); script injected into the app page writes the canary,
   runs an inline onerror handler and hands a file: URL to the operating system; and the page
   is granted the notification permission. The tests never open a browser or another program:
   main.mjs writes links to a file instead when NOTES_EXTERNAL_LOG is set, as the tests set it.

5. Repair exercise.mjs and exercise.preload.cjs
   Electron's checklist item numbers are in brackets.
   - windowPreferences: contextIsolation: true, sandbox: true, nodeIntegration: false, and
     nothing that turns them back on for frames, workers or <webview> [2, 3, 4, 6, 8].
   - responseHeaders: a Content-Security-Policy that starts from default-src 'none', allows
     script-src 'self' only, and sets base-uri, which does not fall back to default-src [7].
   - The preload: exactly listNotes, saveNote and openLink, each sending one fixed channel with
     a fixed payload shape. No invoke(channel), no ipcRenderer, no event objects [20].
   - isTrustedSender: compare event.senderFrame.origin with app://bundle, require a top-level
     frame, and treat a null senderFrame as untrusted [17].
   - parseSaveRequest: exactly {id, text}; the id from an allowlist pattern, because it becomes
     a file name; text a string with a size limit.
   - parseExternalUrl: https: links only, without user names or passwords [15].
   - registerHandlers: the three named channels only, each checking the sender, then the
     arguments, before any side effect. Delete file:read and file:write.
   - hardenWebContents: stop will-navigate to anything outside app://bundle, and make
     setWindowOpenHandler return {action: 'deny'}; hand https: links to the browser [13, 14].
   - configureSession: deny every permission request and check [5].
   Repeat npm test and npm run test:e2e until all 24 and all 9 pass.

6. Compare with the reference
   macOS/Linux: EXERCISE_MODULE=reference npm test
                EXERCISE_MODULE=reference npm run test:e2e
   PowerShell:  $env:EXERCISE_MODULE='reference'; npm test; npm run test:e2e
   Clear that environment variable before testing your own code.
   Expected: 24 of 24 and 9 of 9.

Worked reasoning
Blocking navigation is not enough on its own. will-navigate does not fire when the main process
calls loadURL, a redirect or an app feature can still put foreign content in the window, and the
preload runs again on whatever page loads, so window.notes is there for it; one end-to-end test
loads the untrusted page from the main process for exactly that reason. Only the handler's
sender check refuses it. Check senderFrame.origin rather than parsing the URL: about:blank,
blob: and sandboxed documents have URLs that do not say who controls them, which is why
Electron's own checklist example now compares origins. The app:// scheme is registered as
standard, so Chromium gives app://bundle a real origin; Node's new URL('app://bundle/').origin
is the string "null", because WHATWG URL treats app: as an opaque scheme. Under file://, every
local HTML file reports the same origin, "file://", so a sender check could not tell the app's
page from any other file; that, and file:// pages' access to local files, is why the checklist
prefers a custom protocol that serves a fixed set of files. Even a trusted sender gets only validated arguments: an XSS in the app page has the
page's full authority, so the handlers, not the page, decide which file a save may touch.
Since Electron 29, passing ipcRenderer itself through contextBridge yields an empty object, but
a pass-through invoke(channel, ...args) is the same mistake in a form that still works.

Completion criteria
All 24 fast tests and all 9 Electron tests pass. For each of the 8 end-to-end exploits, name the
control that stopped it in your version and the control behind it that would still stop it if
the first were removed.

Execution status
On 11 October 2026 the tests ran with Electron 44.7.0, playwright-core 1.64.0 and Node.js
26.3.0 on macOS 27 (Apple silicon): the starter passed 5 of 24 fast tests and 1 of 9 Electron
tests, and the reference passed 24 of 24 and 9 of 9. The same counts came out in the pinned dev
container image (Debian trixie, arm64) with the Xvfb steps under Dev container below.

Dev container
.devcontainer/devcontainer.json opens this folder in VS Code Dev Containers or GitHub
Codespaces using the Dev Containers Node.js 24 image, pinned by digest, and runs
npm ci --ignore-scripts. npm test runs there as is. The Electron tests need a display and
libraries the image lacks; in its terminal:
   sudo apt-get update
   sudo apt-get install -y --no-install-recommends xvfb xauth libgtk-3-0t64 libnss3 libasound2t64 libgbm1
   npx install-electron --no
   sudo chown root:root node_modules/electron/dist/chrome-sandbox
   sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
   xvfb-run -a npm run test:e2e
The two chrome-sandbox commands give Chromium's setuid sandbox helper the owner and mode it
needs where the container does not allow unprivileged user namespaces. Never work around a
sandbox error with --no-sandbox: that removes the renderer sandbox this project tests.

Limits
A teaching app with one page and three operations. The tests run the development build: a
packaged app also needs fuses (RunAsNode off, ASAR integrity on), code signing and a supported
Electron release; check those on the packaged build. Playwright's Electron support is
experimental and needs the nodeCliInspect fuse left on, so run these tests before you flip
fuses. The untrusted page is local and minimal; it demonstrates each weakness only as far as a
regression test needs.

Primary references
https://www.electronjs.org/docs/latest/tutorial/security
https://www.electronjs.org/docs/latest/tutorial/context-isolation
https://www.electronjs.org/docs/latest/tutorial/sandbox
https://www.electronjs.org/docs/latest/api/structures/ipc-main-invoke-event
https://www.electronjs.org/docs/latest/api/web-frame-main
https://www.electronjs.org/docs/latest/api/web-contents
https://www.electronjs.org/docs/latest/api/protocol
https://www.electronjs.org/docs/latest/breaking-changes
https://www.electronjs.org/docs/latest/tutorial/installation
https://playwright.dev/docs/api/class-electron
