super-productivity/electron/navigation-guard.ts
Johannes Millan 880cfdcaf7
fix(electron): lock in-window navigation to the app's loaded origin (#8230)
* fix(electron): lock in-window navigation to the app's loaded origin

The previous will-navigate handler accepted any URL whose hostname was
'localhost' or '127.0.0.1', which left the privileged main window
reachable by any local web server (e.g. http://127.0.0.1:1337/) — the
preload bridge (window.ea) would have been exposed to whatever page that
server returned. In production the legitimate origin is file:// only, so
there is no excuse for ever navigating to http://localhost in-window.

Compare against the URL the app actually loaded (captured at loadURL
time) instead of a hostname allowlist. http/https requires exact
host+port match; file:// requires the same html pathname; data:/blob:/
javascript: are rejected outright. The same guard is also applied to
will-redirect so a same-origin start cannot be redirected onto an
attacker page mid-navigation, and a defensive did-create-window handler
destroys any unexpected child window (the deny-all setWindowOpenHandler
should make that path unreachable).

Also tightens TO_FILE_URL with the same userData deny used by the other
file-sync IPCs, so a plugin/XSS cannot launder a userData path into a
file:// URL that later flows through READ_LOCAL_IMAGE_AS_DATA_URL.

* fix(electron): reject UNC/remote-host file:// in navigation guard

`new URL('file://192.168.1.100/Applications/SP.app/Contents/Resources/index.html').pathname`
equals the local app's pathname, so the previous pathname-only check
considered an attacker-controlled UNC host same-origin and let it load
in the privileged main window. The app's loaded file:// URL is always
local (empty host), so require `target.host === ''` explicitly.

* test(electron): add userinfo-@-trick navigation guard regression

`http://localhost:4200@evil.com/` parses with host=evil.com and
username=localhost — a naive substring or startsWith check would be
fooled. The existing host-equality check already rejects it; lock that
in with a test so a future refactor cannot regress.
2026-06-09 22:34:15 +02:00

46 lines
2 KiB
TypeScript

/**
* Returns true if `targetUrl` is the same origin as the app's loaded URL.
*
* Security boundary: the main window has Node integration / preload bridge.
* A navigation to ANY other origin in-window would expose `window.ea` to
* untrusted content (e.g. http://127.0.0.1:<any> hosting a malicious page).
* `will-navigate` MUST reject anything this returns false for and hand it
* to the (scheme-guarded) external-open path instead.
*
* Comparison rules:
* - Protocols must match exactly.
* - http/https: exact host (incl. port) match. Subdomains differ → different
* origin. This is what blocks `localhost.evil.com` and `http://127.0.0.1:1`.
* - file: BOTH host AND pathname must match the app's loaded html file.
* `file://` URLs may carry a host (UNC paths / remote shares), and Chromium
* resolves the pathname relative to that host — so a target like
* `file://192.168.1.100/Applications/SP.app/Contents/Resources/index.html`
* has the same `.pathname` as the local app start URL. A pathname-only
* check would let an attacker-controlled UNC host load in the privileged
* window. The app's loaded file:// URL is always local (empty host), so we
* require `target.host === ''` explicitly rather than relying on the
* host-equality compare to coincidentally match.
* Hash-only changes do not fire `will-navigate`, so a same-document hash
* route never reaches this check.
* - Anything else (data:, blob:, javascript:, ftp:, …): rejected.
*/
export const isAppOriginUrl = (targetUrl: string, appUrl: string): boolean => {
let target: URL;
let expected: URL;
try {
target = new URL(targetUrl);
expected = new URL(appUrl);
} catch {
return false;
}
if (target.protocol !== expected.protocol) return false;
if (target.protocol === 'http:' || target.protocol === 'https:') {
return target.host === expected.host;
}
if (target.protocol === 'file:') {
return (
target.host === '' && expected.host === '' && target.pathname === expected.pathname
);
}
return false;
};