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)}, ) 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 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)) 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} 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) 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"))) diff --git a/electron/main.js b/electron/main.js index fbd8acf..b874e0a 100644 --- a/electron/main.js +++ b/electron/main.js @@ -9,6 +9,7 @@ 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']); @@ -48,16 +49,17 @@ function assertSafeFilePath(filePath) { } function isTrustedAppUrl(url) { - if (isDev) { - return 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); - } + if (url.startsWith('https://')) void shell.openExternal(url); } function createWindow() { @@ -71,81 +73,60 @@ 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: [ @@ -158,7 +139,8 @@ ipcMain.handle('dialog:openFile', async (_event, options) => { return result.canceled ? null : result.filePaths[0]; }); -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, @@ -166,7 +148,8 @@ ipcMain.handle('dialog:openDirectory', async (_event, options) => { return result.canceled ? null : 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'] }, @@ -177,79 +160,60 @@ ipcMain.handle('dialog:saveFile', async (_event, options) => { return result.canceled ? null : 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]; }); -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.'); + 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('get-backend-url', () => { - return `http://localhost:${BACKEND_PORT}`; +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('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('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 }; }); +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); shell.showItemInFolder(filePath); return true; }); - -ipcMain.handle('shell:openPath', async (_event, filePath) => { +ipcMain.handle('shell:openPath', async (event, filePath) => { + assertTrustedSender(event); const error = await shell.openPath(filePath); return error || true; -}); +}); \ No newline at end of file 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 diff --git a/package.json b/package.json index 320ee01..8f8ccc4 100644 --- a/package.json +++ b/package.json @@ -4,26 +4,10 @@ "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: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", @@ -48,48 +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/**/*", - "backend/**/*", - "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