super-productivity/electron/full-screen-blocker.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

85 lines
2.8 KiB
TypeScript

import { BrowserWindow, BrowserWindowConstructorOptions, ipcMain } from 'electron';
import { IPC } from './shared-with-frontend/ipc-events.const';
import { TakeABreakConfig } from '../src/app/features/config/global-config.model';
import { join, normalize } from 'path';
import { assertSecureWebPreferences } from './web-preferences-guard';
export const initFullScreenBlocker = (IS_DEV: boolean): void => {
let isFullScreenWindowOpen = false;
ipcMain.on(
IPC.FULL_SCREEN_BLOCKER,
(
ipcEvent,
{ msg, takeABreakCfg }: { msg: string; takeABreakCfg: TakeABreakConfig },
): void => {
if (isFullScreenWindowOpen) {
return;
}
let isClosable = false;
// This overlay loads a local file with no preload bridge, so it was
// relying on Electron's secure defaults. Set the boundary explicitly and
// assert it, so a future Electron default change can't silently open it.
const webPreferences: BrowserWindowConstructorOptions['webPreferences'] = {
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
};
assertSecureWebPreferences(webPreferences, 'full-screen-blocker');
const win = new BrowserWindow({
title: msg,
fullscreen: true,
alwaysOnTop: true,
transparent: true,
skipTaskbar: true,
frame: false,
webPreferences,
});
const randomImgUrl = takeABreakCfg.motivationalImgs?.length
? takeABreakCfg.motivationalImgs[
Math.floor(Math.random() * takeABreakCfg.motivationalImgs.length)
]
: '';
win.setAlwaysOnTop(true, 'floating');
win.setVisibleOnAllWorkspaces(true);
win.setFullScreenable(false);
isFullScreenWindowOpen = true;
win.loadURL(
`file://${normalize(
join(
__dirname,
IS_DEV
? '../src/static/break-reminder-overlay.html'
: '../dist/static/break-reminder-overlay.html',
),
)}` +
`#msg=${encodeURIComponent(msg)}&img=${encodeURIComponent(randomImgUrl ?? '')}&time=${
takeABreakCfg.timedFullScreenBlockerDuration
}`,
);
const closeTimeout = setTimeout(() => {
isClosable = true;
win.close();
}, takeABreakCfg.timedFullScreenBlockerDuration || 5000);
win.on('close', (evI) => {
if (isClosable) {
if (closeTimeout) {
clearTimeout(closeTimeout);
}
isFullScreenWindowOpen = false;
} else {
evI.preventDefault();
}
});
win.on('closed', () => {
// Clean up references
isFullScreenWindowOpen = false;
if (closeTimeout) {
clearTimeout(closeTimeout);
}
});
},
);
};