super-productivity/electron/ipc-handlers/system.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

59 lines
2.3 KiB
TypeScript

import { app, dialog, ipcMain, shell } from 'electron';
import { IPC } from '../shared-with-frontend/ipc-events.const';
import { isExternalUrlSchemeAllowed } from '../shared-with-frontend/is-external-url-allowed';
import { isLocalFileUrl, openLocalPath } from '../open-url';
import { getWin } from '../main-window';
export const initSystemIpc = (): void => {
ipcMain.on(IPC.OPEN_PATH, (ev, path: string) => {
// openLocalPath enforces the guards this sink needs: reject UNC / remote
// file:// paths (NTLM-hash leak) and never launch an executable/script.
// FILE-type task-attachment paths are synced and thus attacker-controllable.
// See GHSA-hr87-735w-hfq3.
openLocalPath(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;
}
// A local file: URL opens reliably via openPath, not openExternal:
// openExternal percent-encodes the path on Windows and then can't resolve
// non-ASCII names or spaces. openLocalPath decodes it and re-applies the
// openPath guards. See issue #8695.
if (isLocalFileUrl(url)) {
openLocalPath(url);
return;
}
shell.openExternal(url);
});
ipcMain.on(
IPC.SHOW_EMOJI_PANEL,
() => app.isEmojiPanelSupported() && app.showEmojiPanel(),
);
ipcMain.handle(IPC.SAVE_FILE_DIALOG, async (ev, { filename, data }) => {
const result = await dialog.showSaveDialog(getWin(), {
defaultPath: filename,
filters: [
{ name: 'JSON Files', extensions: ['json'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (!result.canceled && result.filePath) {
const fs = await import('fs');
await fs.promises.writeFile(result.filePath, data, 'utf-8');
return { success: true, path: result.filePath };
}
return { success: false };
});
ipcMain.handle(IPC.SHARE_NATIVE, async () => {
// Desktop platforms use the share dialog instead of native share
// This allows for more flexibility and better UX with social media options
return { success: false, error: 'Native share not available on desktop' };
});
};