security: scheme-validate URLs handed to shell.openExternal

The open-external IPC was a pass-through:

  ipcMain.handle("open-external", async (_, url) =>
    await shell.openExternal(url));

shell.openExternal on Windows happily resolves any URL scheme the OS
knows how to launch — including file:// paths, ms-settings:, shell:,
javascript:, and assorted protocol handlers. The renderer is
contextIsolated + nodeIntegration: false so direct exploits are
blocked, but an XSS landing through (for example) a streamer name
that smuggled HTML into a renderer template would have a clean path
through this IPC to launch arbitrary local executables via the OS
shell.

Validation gate: reject anything that isn't an http:// or https://
URL. Trim before the test so a smuggled leading/trailing whitespace
attempt does not slip through. Rejected requests get a debug-log
entry (truncated to 200 chars so a megabyte payload doesnt nuke the
log) and return silently — the renderer caller already swallows
the promise without checking, so silent-drop matches existing
behaviour.

Defence-in-depth. No known active exploit; just removing an
unnecessary surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 04:12:51 +02:00
parent 7e60d0e920
commit c6f423b5ac

View File

@ -6975,7 +6975,20 @@ ipcMain.handle('install-update', () => {
}); });
ipcMain.handle('open-external', async (_, url: string) => { ipcMain.handle('open-external', async (_, url: string) => {
await shell.openExternal(url); // Only allow https / http URLs — never let the renderer push a
// file://, javascript:, or shell:-style URL through to the OS
// shell.openExternal handler. The renderer is contextIsolated +
// nodeIntegration: false, but an XSS through (e.g.) a streamer name
// smuggling a payload into a template would otherwise hand the
// attacker shell.openExternal which on Windows happily resolves
// file:///C:/Windows/System32/calc.exe.
if (typeof url !== 'string') return;
const trimmed = url.trim();
if (!/^https?:\/\//i.test(trimmed)) {
appendDebugLog('open-external-rejected', { url: trimmed.slice(0, 200) });
return;
}
await shell.openExternal(trimmed);
}); });
// Tracks active standalone clip downloads so cancel-download / window-all-closed // Tracks active standalone clip downloads so cancel-download / window-all-closed