fix(electron): validate URL scheme before shell.openExternal/openPath (GHSA-hr87-735w-hfq3) (#8210)

* fix(electron): validate URL scheme before shell.openExternal (GHSA-hr87-735w-hfq3)

Task-note Markdown links had their href passed verbatim to shell.openExternal
on click, letting note content (sync, imported backups, issue-provider data)
silently invoke any OS-registered protocol handler — ms-msdt: (Follina),
search-ms:, \\host\share (NTLM hash capture), etc. — with one click and no
prompt. Angular's sanitizer does not help: its SAFE_URL_PATTERN blocks only
javascript:, so these schemes pass.

Add a shared scheme allowlist (http/https/mailto/file) enforced at both layers:

- marked renderer (marked-options-factory): blocked-scheme links render as
  inert <span> text instead of an anchor (covers all note render paths + web).
- Electron main process, both shell.openExternal sinks: the OPEN_EXTERNAL IPC
  handler (system.ts) and openUrlInBrowser (main-window.ts, the
  will-navigate/window-open path used by the fullscreen/archived dialogs).

The allowlist lives in electron/shared-with-frontend so renderer and main agree
on one policy. Blocked links are styled muted/non-interactive with an
explanatory tooltip.

* fix(electron): harden openExternal/openPath against SMB & attribute-injection (GHSA-hr87-735w-hfq3)

Follow-up hardening from multi-agent review of the initial fix:

- file:// SMB bypass: `file:` was allow-listed unconditionally, so
  `file://host/share` / `file:////host` reached shell.openExternal — the same
  NTLM-leak vector the raw `\\host\share` check blocked. Restrict `file:` to the
  canonical local form `file:///<path>`. Gated on the raw string because
  Chromium (renderer) and Node (main) parse file: hosts/paths differently.
- IPC.OPEN_PATH was unguarded: a synced FILE-attachment path of `\\host\share`
  leaked NTLM via shell.openPath. Reject UNC paths (new isUncPath helper).
- Attribute-injection XSS: the marked link renderer interpolated href/title
  raw; marked's angle-bracket link form could inject an event handler that runs
  under disableSanitizer. HTML-escape href/title.
- Remove the now-dead/misleading explicit backslash check and redundant
  toLowerCase (new URL already throws on raw UNC; protocol is spec-lowercase).

Adds cross-engine-verified tests for file:// SMB forms, isUncPath, and the
href quote-injection case.

* fix(electron): block remote file:// in OPEN_PATH too (GHSA-hr87-735w-hfq3)

Re-review found the OPEN_PATH guard was incomplete: isUncPath only rejects raw
slash-prefixed paths, but shell.openPath('file://host/share') resolves to
\\host\share on Windows — the same NTLM leak. A synced FILE-attachment path is
attacker-controllable.

Centralize the path policy in isPathSafeToOpen() (rejects UNC paths and remote
file:// URLs, allows local paths and file:///) so the two sinks can't drift.

* fix(electron): gate image src against remote file:// / UNC (GHSA-hr87-735w-hfq3)

Markdown image src auto-loads on render (no click), so a remote
file://host/share or UNC src silently triggers an SMB connection and
leaks the user's NTLM hash just by viewing a synced/imported note — the
same vector the link/openExternal gate already blocks, but with no user
interaction. CSP (img-src ... file:) does not block it.

Gate image href through isPathSafeToOpen: remote file:// and UNC srcs
render as inert (HTML-escaped) alt text instead of an <img>; local
file://, http(s), data: and blob: images are unaffected.

Also drop a dead eslint-disable directive on ALLOWED_EXTERNAL_URL_SCHEMES
(the name does not trip naming-convention).

* fix(tasks): gate IMG attachment src against remote file:// / UNC (GHSA-hr87-735w-hfq3)

An IMG-type task attachment renders <img [src]="resolvedOriginalPath">,
which auto-loads on render with no click. A synced/imported remote
file://host or UNC path there silently triggers an SMB fetch and leaks
the user's NTLM hash just by opening the task — the same no-click vector
the markdown-image gate already blocks, but via Angular template binding
(Angular's URL sanitizer does not help; SAFE_URL_PATTERN allows file:).

Drop unsafe srcs via isPathSafeToOpen so they never reach the binding;
local file://, http(s), data: and blob: images are unaffected.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Johannes Millan 2026-06-09 17:29:18 +02:00 committed by GitHub
parent 04e6abdf71
commit cd86ba2c0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 428 additions and 12 deletions

View file

@ -1,10 +1,30 @@
import { app, dialog, ipcMain, shell } from 'electron';
import { IPC } from '../shared-with-frontend/ipc-events.const';
import {
isExternalUrlSchemeAllowed,
isPathSafeToOpen,
} from '../shared-with-frontend/is-external-url-allowed';
import { getWin } from '../main-window';
export const initSystemIpc = (): void => {
ipcMain.on(IPC.OPEN_PATH, (ev, path: string) => shell.openPath(path));
ipcMain.on(IPC.OPEN_EXTERNAL, (ev, url: string) => shell.openExternal(url));
ipcMain.on(IPC.OPEN_PATH, (ev, path: string) => {
// Block UNC / network paths and remote file:// URLs: shell.openPath would
// resolve \\host\share (and file://host/share) to an SMB connection and leak
// the user's NTLM hash. FILE-type task-attachment paths are synced and thus
// attacker-controllable. See GHSA-hr87-735w-hfq3.
if (!isPathSafeToOpen(path)) {
return;
}
shell.openPath(path);
});
ipcMain.on(IPC.OPEN_EXTERNAL, (ev, url: string) => {
// Defense in depth: never hand an unsafe scheme to the OS handler, even if
// the renderer-side guard is bypassed. See GHSA-hr87-735w-hfq3.
if (!isExternalUrlSchemeAllowed(url)) {
return;
}
shell.openExternal(url);
});
ipcMain.on(
IPC.SHOW_EMOJI_PANEL,

View file

@ -13,6 +13,7 @@ import { errorHandlerWithFrontendInform } from './error-handler-with-frontend-in
import * as path from 'path';
import { join, normalize } from 'path';
import { IPC } from './shared-with-frontend/ipc-events.const';
import { isExternalUrlSchemeAllowed } from './shared-with-frontend/is-external-url-allowed';
import { readFileSync, stat, writeFileSync } from 'fs';
import { error, log } from 'electron-log/main';
import { IS_MAC, IS_GNOME_DESKTOP } from './common.const';
@ -414,6 +415,14 @@ export const setWasMaximizedBeforeHide = (value: boolean): void => {
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function initWinEventListeners(app: Electron.App): void {
const openUrlInBrowser = (url: string): void => {
// Defense in depth: never hand an unsafe scheme to the OS handler, even if
// a renderer-side guard is bypassed (e.g. a link click that falls through
// to navigation rather than the explicit openExternalUrl IPC). The blocked
// schemes are OS protocol handlers / UNC paths. See GHSA-hr87-735w-hfq3.
if (!isExternalUrlSchemeAllowed(url)) {
error('Refused to open URL with disallowed scheme via openExternal');
return;
}
// needed for mac; especially for jira urls we might have a host like this www.host.de//
const urlObj = new URL(url);
urlObj.pathname = urlObj.pathname.replace('//', '/');

View file

@ -0,0 +1,96 @@
/**
* URL schemes that are permitted to reach the OS handler via shell.openExternal.
*
* Task notes render Markdown links whose href would otherwise be passed verbatim
* to the OS handler on click. Without this gate, anyone who can populate note
* content (multi-device sync, shared/imported backups, issue-provider content)
* could make a single click silently invoke any OS-registered protocol
* `ms-msdt:`, `search-ms:`, etc.
* See GHSA-hr87-735w-hfq3.
*
* Shared between the Angular renderer (link rendering) and the Electron main
* process (shell.openExternal call sites) so both layers enforce one policy.
*/
export const ALLOWED_EXTERNAL_URL_SCHEMES = ['http:', 'https:', 'mailto:', 'file:'];
const _isSlash = (char: string): boolean => char === '/' || char === '\\';
/**
* True for a UNC / network path such as `\\host\share` or `//host/share` i.e.
* any path whose first two characters are slashes (in either direction).
*
* Opening such a path (`shell.openPath` / `shell.openExternal('file://host/…')`)
* makes the OS reach out to a remote SMB host, leaking the user's NTLM hash.
* Local absolute paths (`/home/x`, `C:\x`) and POSIX/Windows roots are NOT UNC.
*/
export const isUncPath = (path: unknown): boolean => {
if (typeof path !== 'string') {
return false;
}
const trimmed = path.trim();
return trimmed.length >= 2 && _isSlash(trimmed[0]) && _isSlash(trimmed[1]);
};
/**
* Returns true only for URLs whose scheme is in ALLOWED_EXTERNAL_URL_SCHEMES.
* Schemeless/relative input and any string that fails URL parsing are rejected.
* `file:` is restricted to LOCAL files a `file:` URL with a remote authority
* (`file://host/share`, or a path-based UNC like `file:////host/share`) is the
* same NTLM-hash-leak vector as a raw `\\host\share` path and is rejected.
*/
export const isExternalUrlSchemeAllowed = (url: unknown): boolean => {
if (typeof url !== 'string') {
return false;
}
const trimmed = url.trim();
if (!trimmed) {
return false;
}
let parsed: URL;
try {
// Raw UNC paths (`\\host\share`, `//host`) and schemeless input throw here.
parsed = new URL(trimmed);
} catch {
return false;
}
// `protocol` is always lower-cased by the WHATWG URL parser.
if (!ALLOWED_EXTERNAL_URL_SCHEMES.includes(parsed.protocol)) {
return false;
}
if (parsed.protocol === 'file:') {
// Allow ONLY the canonical local form `file:///<path>`. Anything with an
// authority (`file://host/…`) or a path-based UNC (`file:////host`) is a
// remote SMB reference and leaks the user's NTLM hash. Gate on the RAW
// string: Chromium (renderer) and Node (main) parse file: hosts/paths
// differently, so `parsed.host` / `parsed.pathname` are not portable.
const lower = trimmed.toLowerCase();
return lower.startsWith('file:///') && lower[8] !== '/' && lower[8] !== '\\';
}
return true;
};
/**
* Returns true if a value is safe to hand to `shell.openPath` (which opens a
* filesystem path, not a URL). Rejects UNC paths AND `file:`-scheme values with
* a remote authority `shell.openPath('file://host/share')` resolves to
* `\\host\share` on Windows, the same NTLM-leak `isUncPath` blocks for the raw
* form. Plain local paths (`/home/x`, `C:\x`, `./rel`) are allowed.
* See GHSA-hr87-735w-hfq3.
*/
export const isPathSafeToOpen = (path: unknown): boolean => {
if (typeof path !== 'string') {
return false;
}
const trimmed = path.trim();
if (!trimmed) {
return false;
}
if (isUncPath(trimmed)) {
return false;
}
// A file:-scheme value must be a LOCAL file URL, not a remote authority.
if (trimmed.toLowerCase().startsWith('file:')) {
return isExternalUrlSchemeAllowed(trimmed);
}
return true;
};