super-productivity/electron/web-preferences-guard.ts
Johannes Millan 463709e2de
fix(electron): assert renderer IPC boundary at window creation (#9018)
* docs(plugins): add microsoft 365 calendar provider plan

* docs(plugins): add microsoft 365 calendar provider plan

* docs(ios): plan internal testflight builds

* fix(electron): assert renderer IPC boundary at window creation

Every IPC trust boundary (Jira one-shot capability, plugin node-exec
consent, the window.ea preload bridge) rests on the renderer main world
not having require/ipcRenderer, which is guaranteed solely by
contextIsolation: true + nodeIntegration: false and sub-frames not
getting node integration. If that webPreferences ever silently regressed
(a refactor spreading a shared options object, a bad merge), every gate
would collapse at once while still looking correct in review.

Add web-preferences-guard.ts (assertSecureWebPreferences) and fail closed
before creating a window if the boundary is not intact. It rejects a
non-true contextIsolation, a non-false nodeIntegration, and (fail-closed)
a nodeIntegrationInSubFrames that is not explicitly false; it also
directionally rejects an explicit sandbox: false, nodeIntegrationInWorker:
true, and webviewTag: true (each off by default, so no call site is
forced to set it). Wire it at all three new BrowserWindow sites (main
window, task widget, full-screen blocker); the full-screen blocker
previously relied on Electron defaults, so set its webPreferences
explicitly. A *.test.cjs backs it with behavioral coverage plus a wiring
guard that counts constructor sites vs guard calls per file, so a future
window cannot silently ship without the check.

Closes #9015

* fix(electron): extend webPreferences guard to webSecurity

Follow-up hardening from the multi-agent review of #9018:

- Reject an explicit `webSecurity: false` (directional, like the sandbox
  /worker/webviewTag trio). With the app's blanket
  Access-Control-Allow-Origin: *, disabling the same-origin policy in a
  node-bridged renderer would widen cross-origin reach — and no call site
  currently guards against it.
- Broaden the wiring-guard test to also require the assert for
  `new BrowserView` / `new WebContentsView`, closing the tripwire's blind
  spot for future non-BrowserWindow renderers (none exist today).
- Correct the fail() comment: the `throw` narrows the type regardless of
  return-vs-throw; fail() returns an Error only to DRY the message.

230/230 electron tests pass; checkFile + prettier clean.
2026-07-15 10:37:10 +02:00

96 lines
4.7 KiB
TypeScript

import { BrowserWindowConstructorOptions } from 'electron';
type WebPreferences = BrowserWindowConstructorOptions['webPreferences'];
/**
* Fail-closed guard for a renderer's security-critical webPreferences.
*
* Every IPC trust boundary in the app — the Jira one-shot capability, plugin
* node-execution consent, the `window.ea` preload bridge — ultimately rests on
* the renderer main world NOT having `require` / `ipcRenderer`. That property is
* guaranteed solely by `contextIsolation: true` + `nodeIntegration: false`, plus
* sub-frames (where untrusted plugin iframes run) not getting node integration.
* If any of those silently regressed — a refactor spreading a shared options
* object, a bad merge, a copy-paste into a new window — every one of those gates
* would collapse at once while still looking correct in a diff.
*
* This asserts the invariant at window creation and throws BEFORE the window
* loads, so an accidental regression fails the app at startup / in CI instead of
* shipping a renderer that plugin code can fully own. It is a tripwire against
* accidental drift, not a defense against a developer who deliberately flips a
* flag (they would delete this call too).
*
* Two kinds of check:
* - The three core boundary flags — `contextIsolation`, `nodeIntegration`,
* `nodeIntegrationInSubFrames` — are **fail-closed**: an omitted/`undefined`
* value is rejected too, so the guard never depends on the Electron default
* staying safe across upgrades. (Sub-frames are included because that flag
* governs whether the preload bridge reaches plugin iframes.)
* - The additional insecure overrides — `sandbox`, `nodeIntegrationInWorker`,
* `webviewTag`, `webSecurity` — are checked **directionally**: only an explicit
* insecure value is rejected; an omitted key keeps Electron's secure default so
* no call site is forced to enumerate them. These stay default-dependent by choice.
*
* Scope notes:
* - Electron exposes no getter for a webContents' *effective* webPreferences, so
* this can only validate the options object we pass to the constructor.
* - The wiring-guard test requires this call for `new BrowserWindow`,
* `new BrowserView`, and `new WebContentsView`. A `<webview>` guest has no such
* constructor and would still need its own validation (e.g. a
* `will-attach-webview` handler) — none of these exist today.
*/
export const assertSecureWebPreferences = (
webPreferences: WebPreferences,
windowLabel: string,
): void => {
// Returns an Error (callers `throw fail(...)`) so the shared message prefix/suffix
// is defined once — mirroring the `throw fail(...)` shape of the sibling guard
// `file-path-guard.ts` (that one also hardens the error for the renderer; here the
// error only ever surfaces in the main process, so it needs no such hardening).
const fail = (detail: string): Error =>
new Error(
`Insecure webPreferences for the "${windowLabel}" window: ${detail}. ` +
'This would collapse the renderer IPC trust boundary — refusing to create the window.',
);
if (!webPreferences) {
throw fail('no webPreferences set (relying on Electron defaults)');
}
// Core boundary flags — fail-closed (reject omitted/undefined too).
if (webPreferences.contextIsolation !== true) {
throw fail(
`contextIsolation must be true (got ${String(webPreferences.contextIsolation)})`,
);
}
if (webPreferences.nodeIntegration !== false) {
throw fail(
`nodeIntegration must be false (got ${String(webPreferences.nodeIntegration)})`,
);
}
if (webPreferences.nodeIntegrationInSubFrames !== false) {
throw fail(
`nodeIntegrationInSubFrames must be false (got ${String(webPreferences.nodeIntegrationInSubFrames)})`,
);
}
// Additional node-capability surfaces — directional (reject explicit insecure
// value only). Disabling the sandbox re-enables full Node in the preload; a
// Node-enabled worker or a <webview> guest would each open a path around the
// IPC/consent boundary.
if (webPreferences.sandbox === false) {
throw fail('sandbox must not be explicitly false');
}
if (webPreferences.nodeIntegrationInWorker === true) {
throw fail('nodeIntegrationInWorker must not be true');
}
if (webPreferences.webviewTag === true) {
throw fail(
'webviewTag must not be true (a <webview> guest needs its own validation)',
);
}
// webSecurity is the same-origin policy rather than a node capability, but with the
// app's blanket Access-Control-Allow-Origin: * an explicit `false` here would widen
// a node-bridged renderer's cross-origin reach — reject it (directional, like above).
if (webPreferences.webSecurity === false) {
throw fail('webSecurity must not be explicitly false');
}
};