From 0a1cb74cb8feac49596667d62bf24cf33b62fcd7 Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Tue, 13 Oct 2026 22:18:47 -0700 Subject: [PATCH] Restrict external navigation origins --- electron/main.js | 5 ++++- electron/renderer-security.js | 15 +++++++++++++ scripts/smoke-packaged-renderer-policy.js | 27 +++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/electron/main.js b/electron/main.js index 1f1784a..29d6c11 100644 --- a/electron/main.js +++ b/electron/main.js @@ -7,6 +7,7 @@ const { MediaReadAllowlist } = require('./file-capabilities'); const { createAtomicTextWriteQueue } = require('./project-file-io'); const { assertTrustedIpcSender, + isAllowedExternalUrl, isTrustedRendererUrl, packagedRendererUrl, } = require('./renderer-security'); @@ -85,7 +86,9 @@ function createApprovedBackendFileUrl(filePath) { } function openExternalUrl(url) { - if (url.startsWith('https://')) void shell.openExternal(url); + if (!isAllowedExternalUrl(url)) return false; + void shell.openExternal(url); + return true; } function createWindow({ hidden = false } = {}) { diff --git a/electron/renderer-security.js b/electron/renderer-security.js index bbda150..14ba5a8 100644 --- a/electron/renderer-security.js +++ b/electron/renderer-security.js @@ -1,6 +1,11 @@ const path = require('path'); const { pathToFileURL } = require('url'); +const ALLOWED_EXTERNAL_ORIGINS = new Set([ + 'https://github.com', + 'https://www.python.org', +]); + function packagedRendererUrl(projectRoot) { return pathToFileURL(path.join(projectRoot, 'frontend', 'dist', 'index.html')).href; } @@ -21,6 +26,15 @@ function isTrustedRendererUrl(url, { isDev, packagedUrl }) { return normalizedDocumentUrl(url) === normalizedDocumentUrl(packagedUrl); } +function isAllowedExternalUrl(value) { + try { + const url = new URL(value); + return url.protocol === 'https:' && ALLOWED_EXTERNAL_ORIGINS.has(url.origin); + } catch { + return false; + } +} + function assertTrustedIpcSender(event, window, policy) { if (!window || event.sender !== window.webContents) { throw new Error('IPC request did not come from the ScriptCut window.'); @@ -35,6 +49,7 @@ function assertTrustedIpcSender(event, window, policy) { module.exports = { assertTrustedIpcSender, + isAllowedExternalUrl, isTrustedRendererUrl, packagedRendererUrl, }; diff --git a/scripts/smoke-packaged-renderer-policy.js b/scripts/smoke-packaged-renderer-policy.js index 34db93f..05ad0ad 100644 --- a/scripts/smoke-packaged-renderer-policy.js +++ b/scripts/smoke-packaged-renderer-policy.js @@ -5,6 +5,7 @@ const path = require('path'); const { pathToFileURL } = require('url'); const { assertTrustedIpcSender, + isAllowedExternalUrl, isTrustedRendererUrl, packagedRendererUrl, } = require('../electron/renderer-security'); @@ -101,6 +102,32 @@ if (/url\.startsWith\(['"]file:\/\//.test(electronMain)) { if (!/assertTrustedIpcSender\(event, mainWindow, RENDERER_POLICY\)/.test(electronMain)) { fail('electron/main.js does not enforce the exact main-frame sender policy'); } +for (const allowedExternal of [ + 'https://github.com/FernandoAbishai/ScriptCut', + 'https://github.com:443/FernandoAbishai/ScriptCut', + 'https://github.com/FernandoAbishai/ScriptCut/issues/new?template=bug_report.yml', + 'https://www.python.org/downloads/macos/', +]) { + if (!isAllowedExternalUrl(allowedExternal)) fail(`expected external destination is blocked: ${allowedExternal}`); +} +for (const blockedExternal of [ + 'http://github.com/FernandoAbishai/ScriptCut', + 'https://github.com.evil.example/ScriptCut', + 'https://github.com@evil.example/ScriptCut', + 'https://github.com:444/FernandoAbishai/ScriptCut', + 'https://python.org/downloads/macos/', + 'https://www.python.org.evil.example/downloads/macos/', + 'javascript:alert(1)', + 'file:///tmp/attacker.html', +]) { + if (isAllowedExternalUrl(blockedExternal)) fail(`unexpected external destination is allowed: ${blockedExternal}`); +} +if (!/isAllowedExternalUrl\(url\)/.test(electronMain)) { + fail('electron/main.js does not apply the external destination allowlist'); +} +if (!/setWindowOpenHandler\([\s\S]*?return\s+\{\s*action:\s*['"]deny['"]\s*\}/.test(electronMain)) { + fail('electron/main.js must keep window.open denied after optional external delegation'); +} if ( signFileCapability('/tmp/example.mp4', 'scriptcut-test-secret') !== 'ee2e8d32cb960a352d3c0c1d628b5ecd494c0848ba36d9a3b41239d96ae0fd68'