super-productivity/electron/open-url.ts
Johannes Millan 4e0e2d9ffd
fix(electron): open file:// URLs with special characters on Windows (#8695) (#8698)
* fix(electron): open file:// URLs with special characters (#8695)

Local file:// URLs with non-ASCII names or spaces (e.g. Grüne, "Another
One") failed to open on Windows: both open sinks handed the URL to
shell.openExternal, which percent-encodes the path (ü → %C3%BC, space →
%20) before ShellExecute, so the OS then searched for a literally-named
folder and failed. Pure-ASCII paths worked because there was nothing to
encode.

Route local file: URLs through shell.openPath with a decoded filesystem
path (fileURLToPath) instead. openPath takes a raw path, so Unicode names
and spaces open correctly. This also applies the executable-extension
guard to file: URLs, which previously only ran on the OPEN_PATH sink
(GHSA-hr87-735w-hfq3).

Shared openLocalPath()/isLocalFileUrl() cover both the OPEN_EXTERNAL IPC
handler and the navigation-interception path in main-window.

* test(electron): cover NTLM/exec vectors at the un-pre-gated openPath sink

Follow-up to the #8695 review. Add regression tests locking in that
openLocalPath alone (OPEN_PATH has no scheme pre-gate) blocks the
path-based UNC file: URL (file:////host/share, GHSA-hr87-735w-hfq3) and
an executable hidden behind a percent-encoded dot (evil%2Ebat). Document
that the decode tests run on Linux and therefore don't exercise the
Windows drive-letter/backslash conversion, and clarify why isLocalFileUrl
is intentionally broader than the renderer/scheme-allowlist checks.
2026-07-02 11:47:51 +02:00

49 lines
2 KiB
TypeScript

import { shell } from 'electron';
import { fileURLToPath } from 'node:url';
import {
hasExecutableFileExtension,
isPathSafeToOpen,
} from './shared-with-frontend/is-external-url-allowed';
/**
* True for anything shaped like a `file:` URL. Intentionally broad: at the
* OPEN_PATH sink there is no `isExternalUrlSchemeAllowed` pre-gate, so this must
* catch every `file:`-shape value and hand it to `openLocalPath`, which
* re-validates the decoded path. (At the OPEN_EXTERNAL / navigation sinks,
* `isExternalUrlSchemeAllowed` has already narrowed the input to a canonical
* `file:///<path>`.) Note this is deliberately looser than that check and than
* the renderer's `startsWith('file://')` guard — the safety comes from the
* post-decode guards in `openLocalPath`, not from this test.
*/
export const isLocalFileUrl = (value: string): boolean => /^\s*file:/i.test(value);
/**
* Open a local filesystem path — or a local `file:` URL — with the OS default
* handler.
*
* A `file:` URL is decoded to a real filesystem path first: on Windows,
* `shell.openExternal` hands ShellExecute a Chromium-percent-encoded URL
* (`ü` → `%C3%BC`, space → `%20`), which then searches for a literally-named
* folder and fails to open it. `fileURLToPath` decodes those escapes and
* converts `/C:/…` → `C:\…`, so folders/files with non-ASCII names or spaces
* open correctly. See issue #8695.
*
* Enforces the two guards required at every `openPath` sink: reject UNC /
* remote paths (they make the OS reach a remote SMB host and leak the user's
* NTLM hash) and never launch an executable/script. See GHSA-hr87-735w-hfq3.
*/
export const openLocalPath = (pathOrFileUrl: string): void => {
let fsPath = pathOrFileUrl;
if (isLocalFileUrl(pathOrFileUrl)) {
try {
fsPath = fileURLToPath(pathOrFileUrl.trim());
} catch {
// Malformed file: URL (e.g. a remote authority fileURLToPath rejects).
return;
}
}
if (!isPathSafeToOpen(fsPath) || hasExecutableFileExtension(fsPath)) {
return;
}
shell.openPath(fsPath);
};