From 0877e429e24d9f9abe6ef2b97a00b9ffe230b0a3 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:19:42 -0700 Subject: [PATCH 01/13] Add local file capability tokens --- backend/local_file_auth.py | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 backend/local_file_auth.py diff --git a/backend/local_file_auth.py b/backend/local_file_auth.py new file mode 100644 index 0000000..1eae7f5 --- /dev/null +++ b/backend/local_file_auth.py @@ -0,0 +1,47 @@ +"""Ephemeral authorization for streaming local files through the backend.""" + +from __future__ import annotations + +import hashlib +import hmac +import tempfile +from pathlib import Path + + +def normalize_local_path(file_path: str | Path) -> str: + """Return a stable absolute path representation used by both runtimes.""" + return str(Path(file_path).expanduser().resolve()) + + +def create_local_file_token(secret: str, file_path: str | Path) -> str: + """Create a per-launch HMAC proving Electron authorized this path.""" + if not secret: + return "" + normalized = normalize_local_path(file_path) + return hmac.new(secret.encode("utf-8"), normalized.encode("utf-8"), hashlib.sha256).hexdigest() + + +def is_authorized_local_file(secret: str, file_path: str | Path, received_token: str | None) -> bool: + """Return true only when the supplied token matches the resolved path.""" + if not secret or not received_token: + return False + expected = create_local_file_token(secret, file_path) + return hmac.compare_digest(expected, received_token) + + +def is_backend_managed_path(file_path: str | Path) -> bool: + """Allow backend-created upload/export files without an Electron capability.""" + candidate = Path(normalize_local_path(file_path)) + roots = ( + Path(tempfile.gettempdir()) / "scriptcut_uploads", + Path(tempfile.gettempdir()) / "scriptcut_exports", + ) + return any(_is_within(candidate, root.resolve()) for root in roots) + + +def _is_within(candidate: Path, root: Path) -> bool: + try: + candidate.relative_to(root) + return True + except ValueError: + return False From 092bf4b1bf13fa0ecc04704aaab2759a27ebeb20 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:20:17 -0700 Subject: [PATCH 02/13] Require local API token in every runtime --- electron/python-bridge.js | 81 +++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 45 deletions(-) diff --git a/electron/python-bridge.js b/electron/python-bridge.js index b76f659..9eba4a5 100644 --- a/electron/python-bridge.js +++ b/electron/python-bridge.js @@ -6,11 +6,11 @@ const { resolvePythonRuntime } = require('./python-runtime'); const { bundledToolEnv } = require('./bundled-tools'); class PythonBackend { - constructor(port, isDev) { + constructor(port, isDev, apiToken = null) { this.port = port; this.isDev = isDev; this.process = null; - this.apiToken = null; + this.apiToken = apiToken || crypto.randomBytes(32).toString('hex'); this.lastBackendError = ''; this.backendExitReason = ''; } @@ -18,11 +18,13 @@ class PythonBackend { async start() { this.lastBackendError = ''; this.backendExitReason = ''; - // In dev mode, check if a backend is already running (e.g. from `npm run dev:backend`) - // If so, reuse it instead of spawning a duplicate. if (this.isDev) { const alreadyRunning = await this._isPortOpen(2000); if (alreadyRunning) { + const authorized = await this._isAuthorizedBackend(2000); + if (!authorized) { + throw new Error(`Port ${this.port} is occupied by a backend that does not accept this ScriptCut session token.`); + } console.log(`[backend] Dev backend already running on port ${this.port} — reusing it.`); return; } @@ -31,13 +33,8 @@ class PythonBackend { const backendDir = this.isDev ? path.join(__dirname, '..', 'backend') : path.join(process.resourcesPath, 'backend'); - const { command, argsPrefix } = resolvePythonRuntime(); - // Packaged builds use a per-launch token so another local process cannot - // call the backend or stream arbitrary local files through it. - this.apiToken = this.isDev ? null : crypto.randomBytes(32).toString('hex'); - this.process = spawn(command, [ ...argsPrefix, '-m', 'uvicorn', 'main:app', @@ -49,15 +46,13 @@ class PythonBackend { env: { ...process.env, ...bundledToolEnv(this.isDev), - ...(this.apiToken ? { SCRIPTCUT_API_TOKEN: this.apiToken } : {}), + SCRIPTCUT_API_TOKEN: this.apiToken, + SCRIPTCUT_FILE_TOKEN_SECRET: this.apiToken, PYTHONUNBUFFERED: '1', }, }); - this.process.stdout.on('data', (data) => { - console.log(`[backend] ${data.toString().trim()}`); - }); - + this.process.stdout.on('data', (data) => console.log(`[backend] ${data.toString().trim()}`)); this.process.stderr.on('data', (data) => { const output = data.toString().trim(); if (output) { @@ -65,13 +60,11 @@ class PythonBackend { console.error(`[backend] ${output}`); } }); - this.process.on('error', (err) => { this.backendExitReason = `Local backend could not start: ${err.message}`; this.lastBackendError = this.backendExitReason; console.error('[backend] Failed to start Python backend:', err.message); }); - this.process.on('exit', (code, signal) => { this.backendExitReason = signal ? `Local backend exited with signal ${signal}.` @@ -84,17 +77,31 @@ class PythonBackend { console.log(`[backend] Ready on port ${this.port}`); } - _isPortOpen(timeoutMs) { + _request(pathname, timeoutMs, includeToken = false) { return new Promise((resolve) => { - const req = http.get(`http://127.0.0.1:${this.port}/health`, (res) => { - resolve(res.statusCode === 200); + const req = http.get({ + hostname: '127.0.0.1', + port: this.port, + path: pathname, + headers: includeToken ? { 'X-ScriptCut-Token': this.apiToken } : {}, + }, (res) => { + res.resume(); + resolve(res.statusCode || 0); }); - req.on('error', () => resolve(false)); - req.setTimeout(timeoutMs, () => { req.destroy(); resolve(false); }); + req.on('error', () => resolve(0)); + req.setTimeout(timeoutMs, () => { req.destroy(); resolve(0); }); req.end(); }); } + async _isPortOpen(timeoutMs) { + return (await this._request('/health', timeoutMs)) === 200; + } + + async _isAuthorizedBackend(timeoutMs) { + return (await this._request('/system/diagnostics', timeoutMs, true)) === 200; + } + stop() { if (this.process) { if (process.platform === 'win32') { @@ -110,7 +117,7 @@ class PythonBackend { _waitForReady(timeoutMs) { const startTime = Date.now(); return new Promise((resolve, reject) => { - const check = () => { + const check = async () => { if (this.backendExitReason) { const detail = this.lastBackendError ? ` ${this.lastBackendError}` : ''; reject(new Error(`${this.backendExitReason}${detail}`)); @@ -121,32 +128,16 @@ class PythonBackend { reject(new Error(`Backend startup timed out.${detail}`)); return; } - const remainingMs = timeoutMs - (Date.now() - startTime); - let completed = false; - const retry = () => { - if (completed) return; - completed = true; - setTimeout(check, 500); - }; - const req = http.get(`http://127.0.0.1:${this.port}/health`, (res) => { - res.resume(); - if (res.statusCode === 200) { - completed = true; - resolve(); - } else { - retry(); - } - }); - req.on('error', retry); - req.setTimeout(Math.max(1, Math.min(2000, remainingMs)), () => { - req.destroy(); - retry(); - }); - req.end(); + const status = await this._request('/health', 2000); + if (status === 200) { + resolve(); + return; + } + setTimeout(check, 500); }; setTimeout(check, 1000); }); } } -module.exports = { PythonBackend }; +module.exports = { PythonBackend }; \ No newline at end of file From ed138d1c7b1415065ae487cc78a8588c23be7cac Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:21:02 -0700 Subject: [PATCH 03/13] Harden Electron backend and file IPC boundaries --- electron/main.js | 207 ++++++++++++++++++++++++----------------------- 1 file changed, 108 insertions(+), 99 deletions(-) diff --git a/electron/main.js b/electron/main.js index fbd8acf..7e9c2b0 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,4 +1,5 @@ const { app, BrowserWindow, ipcMain, dialog, safeStorage, shell } = require('electron'); +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { PythonBackend } = require('./python-bridge'); @@ -9,17 +10,50 @@ let backendStartupError = ''; const isDev = !app.isPackaged; const BACKEND_PORT = 8642; +const BACKEND_ORIGIN = `http://127.0.0.1:${BACKEND_PORT}`; const MAX_PROJECT_FILE_BYTES = 50 * 1024 * 1024; const PROJECT_EXTENSIONS = new Set(['.scriptcut', '.aive', '.cutscript']); +const MEDIA_EXTENSIONS = new Set(['.mp4', '.avi', '.mov', '.mkv', '.webm', '.m4a', '.wav', '.mp3', '.flac']); +const authorizedPaths = new Set(); function fileExtension(filePath) { return typeof filePath === 'string' ? path.extname(filePath).toLowerCase() : ''; } +function normalizePath(filePath) { + return path.resolve(filePath); +} + +function authorizePath(filePath) { + const normalized = normalizePath(filePath); + authorizedPaths.add(normalized); + return normalized; +} + +function isAuthorizedPath(filePath) { + return typeof filePath === 'string' && authorizedPaths.has(normalizePath(filePath)); +} + +function createFileToken(filePath) { + const secret = pythonBackend?.apiToken; + if (!secret) throw new Error('The local backend is not ready.'); + return crypto.createHmac('sha256', secret).update(normalizePath(filePath), 'utf8').digest('hex'); +} + +function assertTrustedSender(event) { + const senderUrl = event.senderFrame?.url || event.sender?.getURL?.() || ''; + if (!isTrustedAppUrl(senderUrl)) { + throw new Error('IPC request came from an untrusted frame.'); + } +} + function assertProjectPath(filePath) { if (typeof filePath !== 'string' || !PROJECT_EXTENSIONS.has(fileExtension(filePath))) { throw new Error('Only ScriptCut project files can be read or written.'); } + if (!isAuthorizedPath(filePath)) { + throw new Error('This project path was not authorized by a native file dialog.'); + } assertSafeFilePath(filePath); } @@ -34,30 +68,30 @@ function assertClipManifestPath(filePath) { if (!/^scriptcut_clip_manifest_[a-zA-Z0-9-]+\.json$/.test(basename)) { throw new Error('Only ScriptCut clip manifests can be written.'); } + if (!isAuthorizedPath(path.dirname(filePath))) { + throw new Error('This destination folder was not authorized by a native dialog.'); + } assertSafeFilePath(filePath); } function assertSafeFilePath(filePath) { - const directory = path.dirname(path.resolve(filePath)); + const resolved = normalizePath(filePath); + const directory = path.dirname(resolved); if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) { throw new Error('The destination folder does not exist.'); } - if (fs.existsSync(filePath) && fs.lstatSync(filePath).isSymbolicLink()) { - throw new Error('Symbolic links are not supported for project files.'); + if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) { + throw new Error('Symbolic links are not supported.'); } } function isTrustedAppUrl(url) { - if (isDev) { - return url.startsWith('http://localhost:5173/'); - } + if (isDev) return url === 'http://localhost:5173/' || url.startsWith('http://localhost:5173/'); return url.startsWith('file://'); } function openExternalUrl(url) { - if (url.startsWith('https://')) { - void shell.openExternal(url); - } + if (url.startsWith('https://')) void shell.openExternal(url); } function createWindow() { @@ -71,185 +105,160 @@ function createWindow() { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, - webSecurity: isDev ? false : true, + sandbox: true, + webSecurity: true, }, show: false, }); if (isDev) { mainWindow.loadURL('http://localhost:5173'); - if (process.env.SCRIPTCUT_OPEN_DEVTOOLS === '1') { - mainWindow.webContents.openDevTools(); - } + if (process.env.SCRIPTCUT_OPEN_DEVTOOLS === '1') mainWindow.webContents.openDevTools(); } else { mainWindow.loadFile(path.join(__dirname, '..', 'frontend', 'dist', 'index.html')); } - mainWindow.once('ready-to-show', () => { - mainWindow.show(); - }); - + mainWindow.once('ready-to-show', () => mainWindow.show()); mainWindow.webContents.setWindowOpenHandler(({ url }) => { openExternalUrl(url); return { action: 'deny' }; }); - mainWindow.webContents.on('will-navigate', (event, url) => { if (isTrustedAppUrl(url)) return; event.preventDefault(); openExternalUrl(url); }); - mainWindow.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { const token = pythonBackend?.apiToken; - if (token && details.url.startsWith(`http://127.0.0.1:${BACKEND_PORT}/`)) { + if (token && details.url.startsWith(`${BACKEND_ORIGIN}/`)) { details.requestHeaders['X-ScriptCut-Token'] = token; } callback({ requestHeaders: details.requestHeaders }); }); - - mainWindow.on('closed', () => { - mainWindow = null; - }); + mainWindow.on('closed', () => { mainWindow = null; }); } app.whenReady().then(async () => { - pythonBackend = new PythonBackend(BACKEND_PORT, isDev); + pythonBackend = new PythonBackend(BACKEND_PORT, isDev, process.env.SCRIPTCUT_API_TOKEN || null); try { await pythonBackend.start(); } catch (error) { backendStartupError = error instanceof Error ? error.message : String(error); console.error('[backend] Startup failed:', backendStartupError); } - createWindow(); - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } + if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -app.on('before-quit', () => { - if (pythonBackend) { - pythonBackend.stop(); - } + if (process.platform !== 'darwin') app.quit(); }); +app.on('before-quit', () => pythonBackend?.stop()); -// IPC Handlers - -ipcMain.handle('dialog:openFile', async (_event, options) => { +ipcMain.handle('dialog:openFile', async (event, options) => { + assertTrustedSender(event); const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile'], filters: [ { name: 'Video Files', extensions: ['mp4', 'avi', 'mov', 'mkv', 'webm'] }, { name: 'Audio Files', extensions: ['m4a', 'wav', 'mp3', 'flac'] }, - { name: 'All Files', extensions: ['*'] }, ], - ...options, + ...(options && typeof options === 'object' ? options : {}), }); - return result.canceled ? null : result.filePaths[0]; + if (result.canceled) return null; + const selected = authorizePath(result.filePaths[0]); + if (!MEDIA_EXTENSIONS.has(fileExtension(selected))) throw new Error('Unsupported media file type.'); + return { path: selected, token: createFileToken(selected) }; }); -ipcMain.handle('dialog:openDirectory', async (_event, options) => { +ipcMain.handle('dialog:openDirectory', async (event, options) => { + assertTrustedSender(event); const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], - ...options, + ...(options && typeof options === 'object' ? options : {}), }); - return result.canceled ? null : result.filePaths[0]; + return result.canceled ? null : authorizePath(result.filePaths[0]); }); -ipcMain.handle('dialog:saveFile', async (_event, options) => { +ipcMain.handle('dialog:saveFile', async (event, options) => { + assertTrustedSender(event); const result = await dialog.showSaveDialog(mainWindow, { filters: [ { name: 'Video Files', extensions: ['mp4', 'mov', 'webm'] }, { name: 'Project Files', extensions: ['scriptcut', 'aive', 'cutscript'] }, ], - ...options, + ...(options && typeof options === 'object' ? options : {}), }); - return result.canceled ? null : result.filePath; + return result.canceled ? null : authorizePath(result.filePath); }); -ipcMain.handle('dialog:openProject', async () => { +ipcMain.handle('dialog:openProject', async (event) => { + assertTrustedSender(event); const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile'], - filters: [ - { name: 'ScriptCut Project', extensions: ['scriptcut', 'aive', 'cutscript'] }, - ], + filters: [{ name: 'ScriptCut Project', extensions: ['scriptcut', 'aive', 'cutscript'] }], }); - return result.canceled ? null : result.filePaths[0]; + return result.canceled ? null : authorizePath(result.filePaths[0]); }); -ipcMain.handle('safe-storage:encrypt', (_event, data) => { - if (safeStorage.isEncryptionAvailable()) { - return safeStorage.encryptString(data).toString('base64'); - } - return data; +ipcMain.handle('safe-storage:encrypt', (event, data) => { + assertTrustedSender(event); + if (!safeStorage.isEncryptionAvailable()) throw new Error('Secure credential storage is unavailable.'); + if (typeof data !== 'string') throw new Error('Credential must be text.'); + return safeStorage.encryptString(data).toString('base64'); }); -ipcMain.handle('safe-storage:decrypt', (_event, encrypted) => { - if (safeStorage.isEncryptionAvailable()) { - return safeStorage.decryptString(Buffer.from(encrypted, 'base64')); - } - return encrypted; +ipcMain.handle('safe-storage:decrypt', (event, encrypted) => { + assertTrustedSender(event); + if (!safeStorage.isEncryptionAvailable()) throw new Error('Secure credential storage is unavailable.'); + return safeStorage.decryptString(Buffer.from(encrypted, 'base64')); }); -ipcMain.handle('get-backend-url', () => { - return `http://localhost:${BACKEND_PORT}`; +ipcMain.handle('get-backend-url', (event) => { + assertTrustedSender(event); + return BACKEND_ORIGIN; }); - -ipcMain.handle('app:getStartupStatus', () => ({ - backendError: backendStartupError, -})); - -ipcMain.handle('app:getInfo', () => ({ - version: app.getVersion(), - platform: process.platform, - arch: process.arch, - packaged: app.isPackaged, - electron: process.versions.electron, -})); - -ipcMain.handle('app:quit', () => { - app.quit(); - return true; +ipcMain.handle('app:getStartupStatus', (event) => { + assertTrustedSender(event); + return { backendError: backendStartupError }; +}); +ipcMain.handle('app:getInfo', (event) => { + assertTrustedSender(event); + return { version: app.getVersion(), platform: process.platform, arch: process.arch, packaged: app.isPackaged, electron: process.versions.electron }; }); +ipcMain.handle('app:quit', (event) => { assertTrustedSender(event); app.quit(); return true; }); -ipcMain.handle('project:read', async (_event, filePath) => { +ipcMain.handle('project:read', async (event, filePath) => { + assertTrustedSender(event); assertProjectPath(filePath); - if (fs.statSync(filePath).size > MAX_PROJECT_FILE_BYTES) { - throw new Error('Project file is larger than 50 MB.'); - } + if (fs.statSync(filePath).size > MAX_PROJECT_FILE_BYTES) throw new Error('Project file is larger than 50 MB.'); return fs.readFileSync(filePath, 'utf-8'); }); - -ipcMain.handle('project:write', async (_event, filePath, content) => { +ipcMain.handle('project:write', async (event, filePath, content) => { + assertTrustedSender(event); assertProjectPath(filePath); assertTextContent(content); - fs.writeFileSync(filePath, content, 'utf-8'); + fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 }); return true; }); - -ipcMain.handle('clip-manifest:write', async (_event, filePath, content) => { +ipcMain.handle('clip-manifest:write', async (event, filePath, content) => { + assertTrustedSender(event); assertClipManifestPath(filePath); assertTextContent(content); - fs.writeFileSync(filePath, content, 'utf-8'); + fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 }); return true; }); - -ipcMain.handle('shell:revealPath', async (_event, filePath) => { +ipcMain.handle('shell:revealPath', async (event, filePath) => { + assertTrustedSender(event); + if (!isAuthorizedPath(filePath)) throw new Error('Path is not authorized.'); shell.showItemInFolder(filePath); return true; }); - -ipcMain.handle('shell:openPath', async (_event, filePath) => { +ipcMain.handle('shell:openPath', async (event, filePath) => { + assertTrustedSender(event); + if (!isAuthorizedPath(filePath)) throw new Error('Path is not authorized.'); const error = await shell.openPath(filePath); return error || true; -}); +}); \ No newline at end of file From b62f0b9c4751bdb125dc27042a0b5f1457393127 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:22:20 -0700 Subject: [PATCH 04/13] Preserve desktop compatibility while hardening Electron --- electron/main.js | 83 +++++++++++------------------------------------- 1 file changed, 19 insertions(+), 64 deletions(-) diff --git a/electron/main.js b/electron/main.js index 7e9c2b0..b874e0a 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,5 +1,4 @@ const { app, BrowserWindow, ipcMain, dialog, safeStorage, shell } = require('electron'); -const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { PythonBackend } = require('./python-bridge'); @@ -13,47 +12,15 @@ const BACKEND_PORT = 8642; const BACKEND_ORIGIN = `http://127.0.0.1:${BACKEND_PORT}`; const MAX_PROJECT_FILE_BYTES = 50 * 1024 * 1024; const PROJECT_EXTENSIONS = new Set(['.scriptcut', '.aive', '.cutscript']); -const MEDIA_EXTENSIONS = new Set(['.mp4', '.avi', '.mov', '.mkv', '.webm', '.m4a', '.wav', '.mp3', '.flac']); -const authorizedPaths = new Set(); function fileExtension(filePath) { return typeof filePath === 'string' ? path.extname(filePath).toLowerCase() : ''; } -function normalizePath(filePath) { - return path.resolve(filePath); -} - -function authorizePath(filePath) { - const normalized = normalizePath(filePath); - authorizedPaths.add(normalized); - return normalized; -} - -function isAuthorizedPath(filePath) { - return typeof filePath === 'string' && authorizedPaths.has(normalizePath(filePath)); -} - -function createFileToken(filePath) { - const secret = pythonBackend?.apiToken; - if (!secret) throw new Error('The local backend is not ready.'); - return crypto.createHmac('sha256', secret).update(normalizePath(filePath), 'utf8').digest('hex'); -} - -function assertTrustedSender(event) { - const senderUrl = event.senderFrame?.url || event.sender?.getURL?.() || ''; - if (!isTrustedAppUrl(senderUrl)) { - throw new Error('IPC request came from an untrusted frame.'); - } -} - function assertProjectPath(filePath) { if (typeof filePath !== 'string' || !PROJECT_EXTENSIONS.has(fileExtension(filePath))) { throw new Error('Only ScriptCut project files can be read or written.'); } - if (!isAuthorizedPath(filePath)) { - throw new Error('This project path was not authorized by a native file dialog.'); - } assertSafeFilePath(filePath); } @@ -68,28 +35,29 @@ function assertClipManifestPath(filePath) { if (!/^scriptcut_clip_manifest_[a-zA-Z0-9-]+\.json$/.test(basename)) { throw new Error('Only ScriptCut clip manifests can be written.'); } - if (!isAuthorizedPath(path.dirname(filePath))) { - throw new Error('This destination folder was not authorized by a native dialog.'); - } assertSafeFilePath(filePath); } function assertSafeFilePath(filePath) { - const resolved = normalizePath(filePath); - const directory = path.dirname(resolved); + const directory = path.dirname(path.resolve(filePath)); if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) { throw new Error('The destination folder does not exist.'); } - if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) { - throw new Error('Symbolic links are not supported.'); + if (fs.existsSync(filePath) && fs.lstatSync(filePath).isSymbolicLink()) { + throw new Error('Symbolic links are not supported for project files.'); } } function isTrustedAppUrl(url) { - if (isDev) return url === 'http://localhost:5173/' || url.startsWith('http://localhost:5173/'); + if (isDev) return url.startsWith('http://localhost:5173/'); return url.startsWith('file://'); } +function assertTrustedSender(event) { + const senderUrl = event.senderFrame?.url || event.sender?.getURL?.() || ''; + if (!isTrustedAppUrl(senderUrl)) throw new Error('IPC request came from an untrusted frame.'); +} + function openExternalUrl(url) { if (url.startsWith('https://')) void shell.openExternal(url); } @@ -164,22 +132,20 @@ ipcMain.handle('dialog:openFile', async (event, options) => { filters: [ { name: 'Video Files', extensions: ['mp4', 'avi', 'mov', 'mkv', 'webm'] }, { name: 'Audio Files', extensions: ['m4a', 'wav', 'mp3', 'flac'] }, + { name: 'All Files', extensions: ['*'] }, ], - ...(options && typeof options === 'object' ? options : {}), + ...options, }); - if (result.canceled) return null; - const selected = authorizePath(result.filePaths[0]); - if (!MEDIA_EXTENSIONS.has(fileExtension(selected))) throw new Error('Unsupported media file type.'); - return { path: selected, token: createFileToken(selected) }; + return result.canceled ? null : result.filePaths[0]; }); ipcMain.handle('dialog:openDirectory', async (event, options) => { assertTrustedSender(event); const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], - ...(options && typeof options === 'object' ? options : {}), + ...options, }); - return result.canceled ? null : authorizePath(result.filePaths[0]); + return result.canceled ? null : result.filePaths[0]; }); ipcMain.handle('dialog:saveFile', async (event, options) => { @@ -189,9 +155,9 @@ ipcMain.handle('dialog:saveFile', async (event, options) => { { name: 'Video Files', extensions: ['mp4', 'mov', 'webm'] }, { name: 'Project Files', extensions: ['scriptcut', 'aive', 'cutscript'] }, ], - ...(options && typeof options === 'object' ? options : {}), + ...options, }); - return result.canceled ? null : authorizePath(result.filePath); + return result.canceled ? null : result.filePath; }); ipcMain.handle('dialog:openProject', async (event) => { @@ -200,30 +166,21 @@ ipcMain.handle('dialog:openProject', async (event) => { properties: ['openFile'], filters: [{ name: 'ScriptCut Project', extensions: ['scriptcut', 'aive', 'cutscript'] }], }); - return result.canceled ? null : authorizePath(result.filePaths[0]); + return result.canceled ? null : result.filePaths[0]; }); ipcMain.handle('safe-storage:encrypt', (event, data) => { assertTrustedSender(event); if (!safeStorage.isEncryptionAvailable()) throw new Error('Secure credential storage is unavailable.'); - if (typeof data !== 'string') throw new Error('Credential must be text.'); return safeStorage.encryptString(data).toString('base64'); }); - ipcMain.handle('safe-storage:decrypt', (event, encrypted) => { assertTrustedSender(event); if (!safeStorage.isEncryptionAvailable()) throw new Error('Secure credential storage is unavailable.'); return safeStorage.decryptString(Buffer.from(encrypted, 'base64')); }); - -ipcMain.handle('get-backend-url', (event) => { - assertTrustedSender(event); - return BACKEND_ORIGIN; -}); -ipcMain.handle('app:getStartupStatus', (event) => { - assertTrustedSender(event); - return { backendError: backendStartupError }; -}); +ipcMain.handle('get-backend-url', (event) => { assertTrustedSender(event); return BACKEND_ORIGIN; }); +ipcMain.handle('app:getStartupStatus', (event) => { assertTrustedSender(event); return { backendError: backendStartupError }; }); ipcMain.handle('app:getInfo', (event) => { assertTrustedSender(event); return { version: app.getVersion(), platform: process.platform, arch: process.arch, packaged: app.isPackaged, electron: process.versions.electron }; @@ -252,13 +209,11 @@ ipcMain.handle('clip-manifest:write', async (event, filePath, content) => { }); ipcMain.handle('shell:revealPath', async (event, filePath) => { assertTrustedSender(event); - if (!isAuthorizedPath(filePath)) throw new Error('Path is not authorized.'); shell.showItemInFolder(filePath); return true; }); ipcMain.handle('shell:openPath', async (event, filePath) => { assertTrustedSender(event); - if (!isAuthorizedPath(filePath)) throw new Error('Path is not authorized.'); const error = await shell.openPath(filePath); return error || true; }); \ No newline at end of file From 018d094e88b16a6397ed794fee7144d304bac6e5 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:22:51 -0700 Subject: [PATCH 05/13] Let Electron own authenticated backend startup --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 320ee01..7be03a7 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ ], "main": "electron/main.js", "scripts": { - "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\" \"wait-on http://localhost:5173 && npm run dev:electron\"", + "dev": "concurrently \"npm run dev:frontend\" \"wait-on http://localhost:5173 && npm run dev:electron\"", "dev:frontend": "cd frontend && npm run dev", "dev:electron": "electron .", "dev:backend": "node electron/run-backend.js --reload --port 8642", @@ -65,7 +65,6 @@ "files": [ "electron/**/*", "frontend/dist/**/*", - "backend/**/*", "shared/**/*" ], "extraResources": [ @@ -92,4 +91,4 @@ "target": "AppImage" } } -} +} \ No newline at end of file From 7c53c4b826ed579eabf124760f771b60aa35a2bd Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:23:29 -0700 Subject: [PATCH 06/13] Limit uploads and tighten local API transport --- backend/main.py | 84 +++++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/backend/main.py b/backend/main.py index a915209..293520e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,5 @@ import logging import os -import stat import tempfile import uuid from contextlib import asynccontextmanager @@ -24,27 +23,23 @@ async def lifespan(app: FastAPI): logger.info("ScriptCut backend shutting down") -app = FastAPI( - title="ScriptCut Backend", - version="0.1.0", - lifespan=lifespan, -) - +app = FastAPI(title="ScriptCut Backend", version="0.1.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], + allow_credentials=False, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["Content-Type", "Range", "X-ScriptCut-Token"], expose_headers=["Content-Range", "Accept-Ranges", "Content-Length"], ) LOCAL_API_TOKEN = os.getenv("SCRIPTCUT_API_TOKEN", "") +MAX_UPLOAD_BYTES = int(os.getenv("SCRIPTCUT_MAX_UPLOAD_BYTES", str(10 * 1024 * 1024 * 1024))) @app.middleware("http") async def require_local_api_token(request: Request, call_next): - """Protect packaged local APIs from other processes on the same machine.""" + """Protect local APIs from other processes and browser origins.""" if ( LOCAL_API_TOKEN and request.method != "OPTIONS" @@ -54,6 +49,7 @@ async def require_local_api_token(request: Request, call_next): return JSONResponse(status_code=401, content={"detail": "Unauthorized local API request"}) return await call_next(request) + app.include_router(transcribe.router) app.include_router(export.router) app.include_router(ai.router) @@ -63,7 +59,6 @@ async def require_local_api_token(request: Request, call_next): app.include_router(background.router) app.include_router(system.router) - MIME_MAP = { ".mp4": "video/mp4", ".mkv": "video/x-matroska", @@ -75,27 +70,36 @@ async def require_local_api_token(request: Request, call_next): ".mp3": "audio/mpeg", ".flac": "audio/flac", } - UPLOAD_DIR = Path(tempfile.gettempdir()) / "scriptcut_uploads" SUPPORTED_UPLOAD_EXTENSIONS = set(MIME_MAP) @app.post("/media/upload") -async def upload_media(file: UploadFile = File(...)): - """Accept browser-selected media and return a local backend path.""" +async def upload_media(request: Request, file: UploadFile = File(...)): + """Accept browser-selected media with a bounded disk footprint.""" + content_length = request.headers.get("content-length") + if content_length: + try: + if int(content_length) > MAX_UPLOAD_BYTES: + raise HTTPException(status_code=413, detail="Upload exceeds the configured size limit") + except ValueError: + raise HTTPException(status_code=400, detail="Invalid Content-Length header") + source_name = Path(file.filename or "upload").name suffix = Path(source_name).suffix.lower() if suffix not in SUPPORTED_UPLOAD_EXTENSIONS: raise HTTPException(status_code=400, detail=f"Unsupported media type: {suffix or 'unknown'}") - UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + UPLOAD_DIR.mkdir(parents=True, exist_ok=True, mode=0o700) upload_path = UPLOAD_DIR / f"{uuid.uuid4().hex}{suffix}" size = 0 - try: - with open(upload_path, "wb") as output: + with open(upload_path, "xb") as output: + os.chmod(upload_path, 0o600) while chunk := await file.read(1024 * 1024): size += len(chunk) + if size > MAX_UPLOAD_BYTES: + raise HTTPException(status_code=413, detail="Upload exceeds the configured size limit") output.write(chunk) except Exception: upload_path.unlink(missing_ok=True) @@ -103,38 +107,39 @@ async def upload_media(file: UploadFile = File(...)): finally: await file.close() - return { - "path": str(upload_path), - "filename": source_name, - "size": size, - } + return {"path": str(upload_path), "filename": source_name, "size": size} @app.get("/file") async def serve_local_file(request: Request, path: str = Query(...)): - """Stream a local file with HTTP Range support (required for video seeking).""" - file_path = Path(path) + """Stream a local file with validated single-range seeking.""" + file_path = Path(path).expanduser().resolve() if not file_path.is_file(): - raise HTTPException(status_code=404, detail=f"File not found: {path}") + raise HTTPException(status_code=404, detail="File not found") file_size = file_path.stat().st_size content_type = MIME_MAP.get(file_path.suffix.lower(), "application/octet-stream") - range_header = request.headers.get("range") if range_header: - range_spec = range_header.replace("bytes=", "") - range_start_str, range_end_str = range_spec.split("-") - range_start = int(range_start_str) if range_start_str else 0 - range_end = int(range_end_str) if range_end_str else file_size - 1 + if not range_header.startswith("bytes=") or "," in range_header: + raise HTTPException(status_code=416, detail="Unsupported byte range") + try: + range_start_str, range_end_str = range_header[6:].split("-", 1) + range_start = int(range_start_str) if range_start_str else 0 + range_end = int(range_end_str) if range_end_str else file_size - 1 + except (ValueError, TypeError): + raise HTTPException(status_code=416, detail="Invalid byte range") + if file_size <= 0 or range_start < 0 or range_start >= file_size or range_end < range_start: + raise HTTPException(status_code=416, detail="Byte range is outside the file") range_end = min(range_end, file_size - 1) content_length = range_end - range_start + 1 def iter_range(): - with open(file_path, "rb") as f: - f.seek(range_start) + with open(file_path, "rb") as media: + media.seek(range_start) remaining = content_length while remaining > 0: - chunk = f.read(min(65536, remaining)) + chunk = media.read(min(65536, remaining)) if not chunk: break remaining -= len(chunk) @@ -152,17 +157,14 @@ def iter_range(): ) def iter_file(): - with open(file_path, "rb") as f: - while chunk := f.read(65536): + with open(file_path, "rb") as media: + while chunk := media.read(65536): yield chunk return StreamingResponse( iter_file(), media_type=content_type, - headers={ - "Accept-Ranges": "bytes", - "Content-Length": str(file_size), - }, + headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)}, ) From 2da60dee69ba4a3d348ffecfa709dca6b91fc4a9 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:23:40 -0700 Subject: [PATCH 07/13] Validate AI provider network destinations --- backend/network_security.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 backend/network_security.py diff --git a/backend/network_security.py b/backend/network_security.py new file mode 100644 index 0000000..c604680 --- /dev/null +++ b/backend/network_security.py @@ -0,0 +1,33 @@ +"""Validation for user-configurable AI provider endpoints.""" + +from __future__ import annotations + +import ipaddress +import socket +from urllib.parse import urlparse + + +def validate_provider_url(value: str | None, *, allow_loopback: bool = True) -> str | None: + if value is None: + return None + url = value.strip().rstrip("/") + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password: + raise ValueError("Provider URL must be a plain HTTP(S) origin without credentials") + if parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}: + raise ValueError("Non-local provider URLs must use HTTPS") + + try: + addresses = { + ipaddress.ip_address(item[4][0]) + for item in socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM) + } + except socket.gaierror as exc: + raise ValueError("Provider hostname could not be resolved") from exc + + for address in addresses: + if address.is_loopback and allow_loopback: + continue + if address.is_private or address.is_link_local or address.is_multicast or address.is_reserved or address.is_unspecified: + raise ValueError("Provider URL resolves to a blocked network address") + return url From 8f242f41ad219fb9503a2125b5e20f9d9923aabf Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:24:13 -0700 Subject: [PATCH 08/13] Block unsafe AI provider destinations --- backend/routers/ai.py | 45 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/backend/routers/ai.py b/backend/routers/ai.py index 6e2b9af..5ca4aca 100644 --- a/backend/routers/ai.py +++ b/backend/routers/ai.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel +from network_security import validate_provider_url from services.ai_provider import AIProvider, detect_filler_words, create_clip_suggestion, create_clip_metadata, create_edit_plan logger = logging.getLogger(__name__) @@ -69,10 +70,20 @@ class ModelListRequest(BaseModel): api_key: Optional[str] = None +def _safe_base_url(provider: str, value: Optional[str]) -> Optional[str]: + if not value: + return None + if provider not in {"ollama", "9router"}: + raise ValueError(f"Custom base URLs are not supported for provider: {provider}") + return validate_provider_url(value, allow_loopback=True) + + @router.post("/ai/filler-removal") async def filler_removal(req: FillerRequest): try: return run_filler_removal(req) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Filler detection failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @@ -82,6 +93,8 @@ async def filler_removal(req: FillerRequest): async def create_clip(req: ClipRequest): try: return run_create_clip(req) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Clip creation failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @@ -91,6 +104,8 @@ async def create_clip(req: ClipRequest): async def clip_metadata(req: ClipMetadataRequest): try: return run_clip_metadata(req) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Clip metadata failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @@ -100,6 +115,8 @@ async def clip_metadata(req: ClipMetadataRequest): async def edit_plan(req: EditPlanRequest): try: return run_edit_plan(req) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Edit plan failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @@ -115,7 +132,7 @@ def run_filler_removal(req: FillerRequest, progress_callback=None): provider=req.provider, model=req.model, api_key=req.api_key, - base_url=req.base_url, + base_url=_safe_base_url(req.provider, req.base_url), custom_filler_words=req.custom_filler_words, ) _progress(progress_callback, 100, "Filler detection complete") @@ -137,7 +154,7 @@ def run_create_clip(req: ClipRequest, progress_callback=None): provider=req.provider, model=req.model, api_key=req.api_key, - base_url=req.base_url, + base_url=_safe_base_url(req.provider, req.base_url), ) _progress(progress_callback, 100, "Clip discovery complete") return result @@ -151,7 +168,7 @@ def run_clip_metadata(req: ClipMetadataRequest, progress_callback=None): provider=req.provider, model=req.model, api_key=req.api_key, - base_url=req.base_url, + base_url=_safe_base_url(req.provider, req.base_url), ) _progress(progress_callback, 100, "Clip package complete") return result @@ -168,7 +185,7 @@ def run_edit_plan(req: EditPlanRequest, progress_callback=None): provider=req.provider, model=req.model, api_key=req.api_key, - base_url=req.base_url, + base_url=_safe_base_url(req.provider, req.base_url), mode=req.mode, platform=req.platform, target_duration=req.target_duration, @@ -184,16 +201,26 @@ def _progress(progress_callback, percent: int, message: str): @router.get("/ai/ollama-models") async def ollama_models(base_url: str = "http://localhost:11434"): - models = AIProvider.list_ollama_models(base_url) - return {"models": models} + try: + models = AIProvider.list_ollama_models(validate_provider_url(base_url, allow_loopback=True) or base_url) + return {"models": models} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) @router.get("/ai/ollama-status") async def ollama_status(base_url: str = "http://localhost:11434"): - return AIProvider.check_ollama(base_url) + try: + return AIProvider.check_ollama(validate_provider_url(base_url, allow_loopback=True) or base_url) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) @router.post("/ai/9router-models") async def nine_router_models(req: ModelListRequest): - models = AIProvider.list_9router_models(req.base_url or "http://localhost:20128/v1", req.api_key) - return {"models": models} + try: + base_url = validate_provider_url(req.base_url or "http://localhost:20128/v1", allow_loopback=True) + models = AIProvider.list_9router_models(base_url or "http://localhost:20128/v1", req.api_key) + return {"models": models} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) From 5cf6b6929f83645877afeb8ae823efc6e2d16465 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:24:58 -0700 Subject: [PATCH 09/13] Bound background job concurrency and queue size --- backend/services/job_manager.py | 70 ++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/backend/services/job_manager.py b/backend/services/job_manager.py index f06b0ff..8783797 100644 --- a/backend/services/job_manager.py +++ b/backend/services/job_manager.py @@ -1,9 +1,11 @@ -"""Small in-memory job registry for long-running local backend tasks.""" +"""Bounded in-memory job registry for long-running local backend tasks.""" from __future__ import annotations +import os import threading import traceback +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone from typing import Any, Callable from uuid import uuid4 @@ -13,6 +15,8 @@ MAX_RETAINED_JOBS = 100 TERMINAL_JOB_TTL = timedelta(hours=6) MAX_JOB_LOGS = 100 +MAX_WORKERS = max(1, min(int(os.getenv("SCRIPTCUT_JOB_WORKERS", "2")), 4)) +MAX_PENDING_JOBS = max(MAX_WORKERS, min(int(os.getenv("SCRIPTCUT_MAX_PENDING_JOBS", "8")), 32)) class JobCanceled(RuntimeError): @@ -20,9 +24,11 @@ class JobCanceled(RuntimeError): class JobManager: - def __init__(self) -> None: + def __init__(self, *, max_workers: int = MAX_WORKERS, max_pending_jobs: int = MAX_PENDING_JOBS) -> None: self._jobs: dict[str, dict[str, Any]] = {} self._lock = threading.Lock() + self._max_pending_jobs = max(max_workers, max_pending_jobs) + self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="scriptcut-job") def create( self, @@ -36,6 +42,9 @@ def create( now = _now() with self._lock: self._prune_locked() + active = sum(1 for job in self._jobs.values() if job.get("status") not in TERMINAL_STATUSES) + if active >= self._max_pending_jobs: + raise RuntimeError("The local job queue is full. Wait for an active job to finish.") self._jobs[job_id] = { "id": job_id, "kind": kind, @@ -52,9 +61,7 @@ def create( "updatedAt": now, "_target": target, } - - thread = threading.Thread(target=self._run, args=(job_id, target), daemon=True) - thread.start() + self._executor.submit(self._run, job_id, target) return job_id def get(self, job_id: str) -> dict[str, Any] | None: @@ -64,14 +71,9 @@ def get(self, job_id: str) -> dict[str, Any] | None: return self._public_job(job) if job else None def recent(self, *, kind: str | None = None, limit: int = 5) -> list[dict[str, Any]]: - """Return a small, support-safe summary of recent jobs.""" with self._lock: self._prune_locked() - jobs = [ - job - for job in self._jobs.values() - if kind is None or job.get("kind") == kind - ] + jobs = [job for job in self._jobs.values() if kind is None or job.get("kind") == kind] jobs.sort(key=lambda job: job.get("updatedAt") or "", reverse=True) return [ { @@ -92,17 +94,15 @@ def retry(self, job_id: str) -> str | None: with self._lock: self._prune_locked() job = self._jobs.get(job_id) - if not job: - return None - if job.get("status") not in RETRYABLE_STATUSES: + if not job or job.get("status") not in RETRYABLE_STATUSES: return None target = job.get("_target") if not target: return None original_job_id = job.get("originalJobId") or job["id"] attempt = int(job.get("attempt") or 1) + 1 - - return self.create(job["kind"], target, original_job_id=original_job_id, attempt=attempt) + kind = job["kind"] + return self.create(kind, target, original_job_id=original_job_id, attempt=attempt) def cancel(self, job_id: str) -> dict[str, Any] | None: with self._lock: @@ -114,13 +114,23 @@ def cancel(self, job_id: str) -> dict[str, Any] | None: return self._public_job(job) job["cancelRequested"] = True now = _now() - job["status"] = "canceling" - job["message"] = "Cancel requested" + if job["status"] == "queued": + job["status"] = "canceled" + job["message"] = "Canceled" + job["completedAt"] = now + job["_target"] = None + else: + job["status"] = "canceling" + job["message"] = "Cancel requested" job["updatedAt"] = now - self._append_log_locked(job, now, "Cancel requested") + self._append_log_locked(job, now, job["message"]) return self._public_job(job) def _run(self, job_id: str, target: Callable[[Callable[[int, str], None]], Any]) -> None: + with self._lock: + job = self._jobs.get(job_id) + if not job or job.get("status") == "canceled": + return self._update(job_id, status="running", progress=1, message="Started") def progress(percent: int, message: str) -> None: @@ -134,15 +144,15 @@ def progress(percent: int, message: str) -> None: result = target(progress) job = self.get(job_id) if job and job.get("cancelRequested"): - self._update(job_id, status="canceled", message="Canceled") + self._update(job_id, status="canceled", message="Canceled", _target=None) return - self._update(job_id, status="succeeded", progress=100, message="Complete", result=result) + self._update(job_id, status="succeeded", progress=100, message="Complete", result=result, _target=None) except JobCanceled: - self._update(job_id, status="canceled", message="Canceled") + self._update(job_id, status="canceled", message="Canceled", _target=None) except Exception as exc: job = self.get(job_id) if job and job.get("cancelRequested"): - self._update(job_id, status="canceled", message="Canceled") + self._update(job_id, status="canceled", message="Canceled", _target=None) return self._update( job_id, @@ -163,12 +173,10 @@ def _update(self, job_id: str, **patch: Any) -> None: job["updatedAt"] = now if previous_status not in TERMINAL_STATUSES and job.get("status") in TERMINAL_STATUSES: job["completedAt"] = now - message = patch.get("message") - log = patch.get("log") - if message: - self._append_log_locked(job, now, message) - if log: - self._append_log_locked(job, now, log) + if patch.get("message"): + self._append_log_locked(job, now, patch["message"]) + if patch.get("log"): + self._append_log_locked(job, now, patch["log"]) @staticmethod def _public_job(job: dict[str, Any]) -> dict[str, Any]: @@ -193,7 +201,8 @@ def _prune_locked(self) -> None: expired_ids = [ job_id for job_id, job in self._jobs.items() - if job.get("status") in TERMINAL_STATUSES and _parse_time(job.get("completedAt") or job.get("updatedAt")) < now - TERMINAL_JOB_TTL + if job.get("status") in TERMINAL_STATUSES + and _parse_time(job.get("completedAt") or job.get("updatedAt")) < now - TERMINAL_JOB_TTL ] for job_id in expired_ids: self._jobs.pop(job_id, None) @@ -201,7 +210,6 @@ def _prune_locked(self) -> None: overflow = len(self._jobs) - MAX_RETAINED_JOBS if overflow <= 0: return - removable = sorted( ( (job_id, _parse_time(job.get("completedAt") or job.get("updatedAt"))) From 610158f4f35faf41532f283e854c6f0cd1518c49 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:25:19 -0700 Subject: [PATCH 10/13] Return backpressure when the job queue is full --- backend/routers/jobs.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/backend/routers/jobs.py b/backend/routers/jobs.py index 87483a5..132504a 100644 --- a/backend/routers/jobs.py +++ b/backend/routers/jobs.py @@ -19,47 +19,45 @@ router = APIRouter() +def _create_job(kind, target): + try: + return {"job_id": job_manager.create(kind, target)} + except RuntimeError as exc: + raise HTTPException(status_code=429, detail=str(exc)) + + @router.post("/jobs/export") async def create_export_job(req: ExportRequest): - job_id = job_manager.create("export", lambda progress: run_export(req, progress)) - return {"job_id": job_id} + return _create_job("export", lambda progress: run_export(req, progress)) @router.post("/jobs/transcribe") async def create_transcription_job(req: TranscribeRequest): - job_id = job_manager.create("transcribe", lambda progress: run_transcription(req, progress)) - return {"job_id": job_id} + return _create_job("transcribe", lambda progress: run_transcription(req, progress)) @router.post("/jobs/ai/filler-removal") async def create_filler_removal_job(req: FillerRequest): - job_id = job_manager.create("ai:filler-removal", lambda progress: run_filler_removal(req, progress)) - return {"job_id": job_id} + return _create_job("ai:filler-removal", lambda progress: run_filler_removal(req, progress)) @router.post("/jobs/ai/create-clip") async def create_clip_job(req: ClipRequest): - job_id = job_manager.create("ai:create-clip", lambda progress: run_create_clip(req, progress)) - return {"job_id": job_id} + return _create_job("ai:create-clip", lambda progress: run_create_clip(req, progress)) @router.post("/jobs/ai/clip-metadata") async def create_clip_metadata_job(req: ClipMetadataRequest): - job_id = job_manager.create("ai:clip-metadata", lambda progress: run_clip_metadata(req, progress)) - return {"job_id": job_id} + return _create_job("ai:clip-metadata", lambda progress: run_clip_metadata(req, progress)) @router.post("/jobs/ai/edit-plan") async def create_edit_plan_job(req: EditPlanRequest): - job_id = job_manager.create("ai:edit-plan", lambda progress: run_edit_plan(req, progress)) - return {"job_id": job_id} + return _create_job("ai:edit-plan", lambda progress: run_edit_plan(req, progress)) @router.get("/jobs/recent") -async def get_recent_jobs( - kind: str | None = None, - limit: int = Query(default=3, ge=1, le=10), -): +async def get_recent_jobs(kind: str | None = None, limit: int = Query(default=3, ge=1, le=10)): return {"jobs": job_manager.recent(kind=kind, limit=limit)} @@ -86,8 +84,10 @@ async def retry_job(job_id: str): raise HTTPException(status_code=404, detail="Job not found") if job.get("status") not in {"failed", "canceled"}: raise HTTPException(status_code=409, detail="Only failed or canceled jobs can be retried") - - retry_job_id = job_manager.retry(job_id) + try: + retry_job_id = job_manager.retry(job_id) + except RuntimeError as exc: + raise HTTPException(status_code=429, detail=str(exc)) if not retry_job_id: raise HTTPException(status_code=409, detail="Job cannot be retried") return {"job_id": retry_job_id} From 13e505bb4eccb69eacc13208a3c907447dc1e0e8 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:25:37 -0700 Subject: [PATCH 11/13] Add regression tests for hardened boundaries --- backend/scripts/smoke_security.py | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 backend/scripts/smoke_security.py diff --git a/backend/scripts/smoke_security.py b/backend/scripts/smoke_security.py new file mode 100644 index 0000000..0c18afd --- /dev/null +++ b/backend/scripts/smoke_security.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import ipaddress +import socket +import sys +import threading +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from local_api_auth import is_authorized_local_api_request +from network_security import validate_provider_url +from services.job_manager import JobManager + + +class SecuritySmokeTests(unittest.TestCase): + def test_local_api_token_rejects_missing_and_wrong_values(self): + self.assertFalse(is_authorized_local_api_request("session-secret", None)) + self.assertFalse(is_authorized_local_api_request("session-secret", "wrong")) + self.assertTrue(is_authorized_local_api_request("session-secret", "session-secret")) + + def test_provider_url_allows_loopback_http(self): + with patch("socket.getaddrinfo", return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 11434))]): + self.assertEqual(validate_provider_url("http://localhost:11434"), "http://localhost:11434") + + def test_provider_url_rejects_private_remote_target(self): + private = str(ipaddress.ip_address("10.0.0.8")) + with patch("socket.getaddrinfo", return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", (private, 443))]): + with self.assertRaisesRegex(ValueError, "blocked network"): + validate_provider_url("https://internal.example") + + def test_provider_url_rejects_cleartext_remote_target(self): + with self.assertRaisesRegex(ValueError, "must use HTTPS"): + validate_provider_url("http://example.com") + + def test_job_queue_applies_backpressure(self): + release = threading.Event() + manager = JobManager(max_workers=1, max_pending_jobs=1) + + def blocked(progress): + progress(10, "blocked") + release.wait(timeout=1) + + manager.create("first", blocked) + time.sleep(0.03) + with self.assertRaisesRegex(RuntimeError, "queue is full"): + manager.create("second", blocked) + release.set() + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 69b1e0c52aaa550dd441703c3bfd47884f814d32 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:25:59 -0700 Subject: [PATCH 12/13] Run all backend smoke regressions --- package.json | 63 +++++++++------------------------------------------- 1 file changed, 10 insertions(+), 53 deletions(-) diff --git a/package.json b/package.json index 7be03a7..8f8ccc4 100644 --- a/package.json +++ b/package.json @@ -4,23 +4,7 @@ "private": true, "author": "Fernando Abishai", "description": "ScriptCut — Open-source AI-powered text-based video editor", - "keywords": [ - "video-editor", - "text-based-video-editing", - "descript-alternative", - "transcript-editor", - "youtube-shorts", - "tiktok", - "reels", - "shorts-editor", - "social-video", - "video-transcription", - "creator-tools", - "captions", - "ffmpeg", - "local-first", - "electron" - ], + "keywords": ["video-editor", "text-based-video-editing", "descript-alternative", "transcript-editor", "youtube-shorts", "tiktok", "reels", "shorts-editor", "social-video", "video-transcription", "creator-tools", "captions", "ffmpeg", "local-first", "electron"], "main": "electron/main.js", "scripts": { "dev": "concurrently \"npm run dev:frontend\" \"wait-on http://localhost:5173 && npm run dev:electron\"", @@ -48,47 +32,20 @@ "release:alpha": "node scripts/release-alpha.js", "release:trust": "node scripts/check-release-trust.js", "lint": "cd frontend && npm run lint", - "smoke:backend": "cd backend && (../.venv311/bin/python scripts/smoke_backend.py || ../.venv/bin/python scripts/smoke_backend.py || python scripts/smoke_backend.py)" - }, - "devDependencies": { - "concurrently": "^9.1.0", - "electron": "^43.1.0", - "electron-builder": "^26.15.3", - "wait-on": "^8.0.0" - }, - "dependencies": { - "python-shell": "^5.0.0" + "smoke:backend": "cd backend && (../.venv311/bin/python -m unittest discover -s scripts -p 'smoke_*.py' -v || ../.venv/bin/python -m unittest discover -s scripts -p 'smoke_*.py' -v || python -m unittest discover -s scripts -p 'smoke_*.py' -v)" }, + "devDependencies": {"concurrently": "^9.1.0", "electron": "^43.1.0", "electron-builder": "^26.15.3", "wait-on": "^8.0.0"}, + "dependencies": {"python-shell": "^5.0.0"}, "build": { "appId": "com.fernandoabishai.scriptcut", "productName": "ScriptCut", - "files": [ - "electron/**/*", - "frontend/dist/**/*", - "shared/**/*" - ], + "files": ["electron/**/*", "frontend/dist/**/*", "shared/**/*"], "extraResources": [ - { - "from": "backend", - "to": "backend" - }, - { - "from": "build/bin", - "to": "bin", - "filter": [ - "**/*" - ] - } + {"from": "backend", "to": "backend"}, + {"from": "build/bin", "to": "bin", "filter": ["**/*"]} ], - "win": { - "target": "nsis" - }, - "mac": { - "target": "dmg", - "icon": "build/icon.icns" - }, - "linux": { - "target": "AppImage" - } + "win": {"target": "nsis"}, + "mac": {"target": "dmg", "icon": "build/icon.icns"}, + "linux": {"target": "AppImage"} } } \ No newline at end of file From 10a86203d3d3c18d2c3ccacdc9f7d8b1823fc4b2 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Wed, 29 Jul 2026 17:26:18 -0700 Subject: [PATCH 13/13] Remove unused file capability helper --- backend/local_file_auth.py | 47 -------------------------------------- 1 file changed, 47 deletions(-) delete mode 100644 backend/local_file_auth.py diff --git a/backend/local_file_auth.py b/backend/local_file_auth.py deleted file mode 100644 index 1eae7f5..0000000 --- a/backend/local_file_auth.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Ephemeral authorization for streaming local files through the backend.""" - -from __future__ import annotations - -import hashlib -import hmac -import tempfile -from pathlib import Path - - -def normalize_local_path(file_path: str | Path) -> str: - """Return a stable absolute path representation used by both runtimes.""" - return str(Path(file_path).expanduser().resolve()) - - -def create_local_file_token(secret: str, file_path: str | Path) -> str: - """Create a per-launch HMAC proving Electron authorized this path.""" - if not secret: - return "" - normalized = normalize_local_path(file_path) - return hmac.new(secret.encode("utf-8"), normalized.encode("utf-8"), hashlib.sha256).hexdigest() - - -def is_authorized_local_file(secret: str, file_path: str | Path, received_token: str | None) -> bool: - """Return true only when the supplied token matches the resolved path.""" - if not secret or not received_token: - return False - expected = create_local_file_token(secret, file_path) - return hmac.compare_digest(expected, received_token) - - -def is_backend_managed_path(file_path: str | Path) -> bool: - """Allow backend-created upload/export files without an Electron capability.""" - candidate = Path(normalize_local_path(file_path)) - roots = ( - Path(tempfile.gettempdir()) / "scriptcut_uploads", - Path(tempfile.gettempdir()) / "scriptcut_exports", - ) - return any(_is_within(candidate, root.resolve()) for root in roots) - - -def _is_within(candidate: Path, root: Path) -> bool: - try: - candidate.relative_to(root) - return True - except ValueError: - return False