diff --git a/electron/main.js b/electron/main.js index 29d6c11..f684739 100644 --- a/electron/main.js +++ b/electron/main.js @@ -8,6 +8,7 @@ const { createAtomicTextWriteQueue } = require('./project-file-io'); const { assertTrustedIpcSender, isAllowedExternalUrl, + isAllowedRendererPermission, isTrustedRendererUrl, packagedRendererUrl, } = require('./renderer-security'); @@ -107,6 +108,13 @@ function createWindow({ hidden = false } = {}) { }, show: false, }); + const rendererSession = mainWindow.webContents.session; + rendererSession.setPermissionCheckHandler((webContents, permission, _requestingOrigin, details) => ( + isAllowedRendererPermission(webContents, permission, details, mainWindow, RENDERER_POLICY) + )); + rendererSession.setPermissionRequestHandler((webContents, permission, callback, details) => { + callback(isAllowedRendererPermission(webContents, permission, details, mainWindow, RENDERER_POLICY)); + }); if (hidden) mainWindow.hide(); if (isDev) { diff --git a/electron/renderer-security.js b/electron/renderer-security.js index 14ba5a8..503a283 100644 --- a/electron/renderer-security.js +++ b/electron/renderer-security.js @@ -5,6 +5,7 @@ const ALLOWED_EXTERNAL_ORIGINS = new Set([ 'https://github.com', 'https://www.python.org', ]); +const ALLOWED_RENDERER_PERMISSIONS = new Set(['clipboard-sanitized-write']); function packagedRendererUrl(projectRoot) { return pathToFileURL(path.join(projectRoot, 'frontend', 'dist', 'index.html')).href; @@ -35,6 +36,23 @@ function isAllowedExternalUrl(value) { } } +function isTrustedRendererWebContents(webContents, window, policy) { + return Boolean( + window + && webContents + && webContents === window.webContents + && webContents.mainFrame + && isTrustedRendererUrl(webContents.mainFrame.url || '', policy) + ); +} + +function isAllowedRendererPermission(webContents, permission, details, window, policy) { + return ALLOWED_RENDERER_PERMISSIONS.has(permission) + && isTrustedRendererWebContents(webContents, window, policy) + && details?.isMainFrame === true + && isTrustedRendererUrl(details?.requestingUrl || '', policy); +} + function assertTrustedIpcSender(event, window, policy) { if (!window || event.sender !== window.webContents) { throw new Error('IPC request did not come from the ScriptCut window.'); @@ -50,6 +68,8 @@ function assertTrustedIpcSender(event, window, policy) { module.exports = { assertTrustedIpcSender, isAllowedExternalUrl, + isAllowedRendererPermission, isTrustedRendererUrl, + isTrustedRendererWebContents, packagedRendererUrl, }; diff --git a/electron/renderer-transport-smoke.js b/electron/renderer-transport-smoke.js index 8c6a391..fce7a56 100644 --- a/electron/renderer-transport-smoke.js +++ b/electron/renderer-transport-smoke.js @@ -53,6 +53,17 @@ function runRendererProbe(window, backendOrigin, mediaPath) { const mediaUrl = await window.electronAPI.getBackendFileUrl(fixturePath); const media = await request(mediaUrl); + const permissionState = async (name) => { + try { + return (await navigator.permissions.query({ name })).state; + } catch (error) { + return `error:${String(error)}`; + } + }; + const permissions = { + clipboardWrite: await permissionState('clipboard-write'), + geolocation: await permissionState('geolocation'), + }; const video = document.createElement('video'); video.preload = 'metadata'; video.src = mediaUrl; @@ -72,6 +83,7 @@ function runRendererProbe(window, backendOrigin, mediaPath) { checks, transcription, media: { status: media.status, bodyBytes: media.bodyBytes || 0 }, + permissions, policyViolations, }; }; @@ -92,6 +104,12 @@ async function runRendererTransportSmoke({ window, backendOrigin, mediaPath }) { if (result.media?.status !== 200 || result.media.bodyBytes <= 0) { throw new Error(`renderer media request returned ${result.media?.status || 0} with ${result.media?.bodyBytes || 0} bytes`); } + if (result.permissions?.clipboardWrite !== 'granted') { + throw new Error(`renderer clipboard-write permission is not granted: ${result.permissions?.clipboardWrite || 'missing'}`); + } + if (result.permissions?.geolocation !== 'denied') { + throw new Error(`renderer geolocation permission is not denied: ${result.permissions?.geolocation || 'missing'}`); + } if (result.policyViolations.length > 0) { throw new Error(`renderer reported CSP violations: ${JSON.stringify(result.policyViolations)}`); } diff --git a/scripts/smoke-packaged-electron-renderer.js b/scripts/smoke-packaged-electron-renderer.js index 2ca2b0b..b33679b 100644 --- a/scripts/smoke-packaged-electron-renderer.js +++ b/scripts/smoke-packaged-electron-renderer.js @@ -94,6 +94,7 @@ async function main() { console.log(`Renderer GET /system/checks: ${result.checks.status} (token-injected authenticated response)`); console.log(`Renderer POST /jobs/transcribe: ${result.transcription.status} (HTTP response, no Failed to fetch)`); console.log(`Renderer media /file: ${result.media.status} (${result.media.bodyBytes} response bytes)`); + console.log(`Renderer permissions: clipboard-write=${result.permissions.clipboardWrite}, geolocation=${result.permissions.geolocation}`); console.log('Renderer CSP violations: none'); console.log('Packaged Electron renderer transport smoke passed.'); } finally { diff --git a/scripts/smoke-packaged-renderer-policy.js b/scripts/smoke-packaged-renderer-policy.js index 05ad0ad..5b12af4 100644 --- a/scripts/smoke-packaged-renderer-policy.js +++ b/scripts/smoke-packaged-renderer-policy.js @@ -6,6 +6,7 @@ const { pathToFileURL } = require('url'); const { assertTrustedIpcSender, isAllowedExternalUrl, + isAllowedRendererPermission, isTrustedRendererUrl, packagedRendererUrl, } = require('../electron/renderer-security'); @@ -128,6 +129,53 @@ if (!/isAllowedExternalUrl\(url\)/.test(electronMain)) { 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'); } +const permissionMainFrame = { url: packagedUrl }; +const permissionWebContents = { mainFrame: permissionMainFrame }; +const permissionWindow = { webContents: permissionWebContents }; +const trustedPermissionDetails = { isMainFrame: true, requestingUrl: packagedUrl }; +if (!isAllowedRendererPermission(permissionWebContents, 'clipboard-sanitized-write', trustedPermissionDetails, permissionWindow, { isDev: false, packagedUrl })) { + fail('trusted renderer clipboard-sanitized-write permission is not allowed'); +} +for (const blockedPermission of ['clipboard-read', 'geolocation', 'media', 'notifications', 'openExternal', 'usb']) { + if (isAllowedRendererPermission(permissionWebContents, blockedPermission, trustedPermissionDetails, permissionWindow, { isDev: false, packagedUrl })) { + fail(`trusted renderer unexpectedly allows permission: ${blockedPermission}`); + } +} +const foreignWebContents = { mainFrame: permissionMainFrame }; +if (isAllowedRendererPermission(foreignWebContents, 'clipboard-sanitized-write', trustedPermissionDetails, permissionWindow, { isDev: false, packagedUrl })) { + fail('foreign webContents received clipboard-sanitized-write permission'); +} +const untrustedWebContents = { mainFrame: { url: 'file:///tmp/attacker.html' } }; +if (isAllowedRendererPermission( + untrustedWebContents, + 'clipboard-sanitized-write', + { isMainFrame: true, requestingUrl: 'file:///tmp/attacker.html' }, + { webContents: untrustedWebContents }, + { isDev: false, packagedUrl }, +)) { + fail('untrusted document received clipboard-sanitized-write permission'); +} +if (isAllowedRendererPermission( + permissionWebContents, + 'clipboard-sanitized-write', + { isMainFrame: false, requestingUrl: packagedUrl }, + permissionWindow, + { isDev: false, packagedUrl }, +)) { + fail('trusted renderer subframe received clipboard-sanitized-write permission'); +} +if (isAllowedRendererPermission( + permissionWebContents, + 'clipboard-sanitized-write', + { isMainFrame: true, requestingUrl: 'file:///tmp/attacker.html' }, + permissionWindow, + { isDev: false, packagedUrl }, +)) { + fail('trusted webContents with a mismatched requesting document received clipboard-sanitized-write permission'); +} +if (!/setPermissionCheckHandler\(/.test(electronMain) || !/setPermissionRequestHandler\(/.test(electronMain)) { + fail('electron/main.js must install both permission check and request handlers'); +} if ( signFileCapability('/tmp/example.mp4', 'scriptcut-test-secret') !== 'ee2e8d32cb960a352d3c0c1d628b5ecd494c0848ba36d9a3b41239d96ae0fd68'