Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { createAtomicTextWriteQueue } = require('./project-file-io');
const {
assertTrustedIpcSender,
isAllowedExternalUrl,
isAllowedRendererPermission,
isTrustedRendererUrl,
packagedRendererUrl,
} = require('./renderer-security');
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions electron/renderer-security.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.');
Expand All @@ -50,6 +68,8 @@ function assertTrustedIpcSender(event, window, policy) {
module.exports = {
assertTrustedIpcSender,
isAllowedExternalUrl,
isAllowedRendererPermission,
isTrustedRendererUrl,
isTrustedRendererWebContents,
packagedRendererUrl,
};
18 changes: 18 additions & 0 deletions electron/renderer-transport-smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -72,6 +83,7 @@ function runRendererProbe(window, backendOrigin, mediaPath) {
checks,
transcription,
media: { status: media.status, bodyBytes: media.bodyBytes || 0 },
permissions,
policyViolations,
};
};
Expand All @@ -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)}`);
}
Expand Down
1 change: 1 addition & 0 deletions scripts/smoke-packaged-electron-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions scripts/smoke-packaged-renderer-policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { pathToFileURL } = require('url');
const {
assertTrustedIpcSender,
isAllowedExternalUrl,
isAllowedRendererPermission,
isTrustedRendererUrl,
packagedRendererUrl,
} = require('../electron/renderer-security');
Expand Down Expand Up @@ -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'
Expand Down
Loading