From 9698433a942d5506ce70538902a78f0e111c78c4 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Wed, 25 Feb 2026 12:45:24 +0000 Subject: [PATCH 1/3] feat: dynamic sidecar port with EADDRINUSE fallback Rust probes port 46123 via TcpListener::bind; if busy, binds port 0 for an OS-assigned ephemeral port. The actual port is stored in LocalApiState, passed to sidecar via LOCAL_API_PORT env, and exposed to frontend via get_local_api_port IPC command. Frontend resolves the port lazily on first API call (with retry-on-failure semantics) and caches it. All hardcoded 46123 references replaced with dynamic getApiBaseUrl()/getLocalApiPort() accessors. CSP connect-src broadened to http://127.0.0.1:* (frame-src unchanged). --- index.html | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/src/main.rs | 54 ++++++++++++++++++++++++++-- src-tauri/tauri.conf.json | 2 +- src/components/LiveNewsPanel.ts | 5 +-- src/components/ServiceStatusPanel.ts | 4 +-- src/services/runtime-config.ts | 14 +++++--- src/services/runtime.ts | 40 ++++++++++++++++++--- src/settings-main.ts | 18 +++++++--- 9 files changed, 117 insertions(+), 24 deletions(-) diff --git a/index.html b/index.html index 045e96bcf..17b17fc44 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b63102756..a984cf1f3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4890,7 +4890,7 @@ dependencies = [ [[package]] name = "world-monitor" -version = "2.5.7" +version = "2.5.8" dependencies = [ "getrandom 0.2.17", "keyring", diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index bd7f67ff6..e93482fcc 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::env; use std::fs::{self, File, OpenOptions}; use std::io::Write; +use std::net::TcpListener; #[cfg(windows)] use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; @@ -18,7 +19,7 @@ use serde_json::{Map, Value}; use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::{AppHandle, Manager, RunEvent, Webview, WebviewUrl, WebviewWindowBuilder, WindowEvent}; -const LOCAL_API_PORT: &str = "46123"; +const DEFAULT_LOCAL_API_PORT: u16 = 46123; const KEYRING_SERVICE: &str = "world-monitor"; const LOCAL_API_LOG_FILE: &str = "local-api.log"; const DESKTOP_LOG_FILE: &str = "desktop.log"; @@ -56,6 +57,7 @@ const SUPPORTED_SECRET_KEYS: [&str; 22] = [ struct LocalApiState { child: Mutex>, token: Mutex>, + port: Mutex>, } /// In-memory cache for keychain secrets. Populated once at startup to avoid @@ -179,6 +181,7 @@ impl PersistentCache { struct DesktopRuntimeInfo { os: String, arch: String, + local_api_port: Option, } fn save_vault(cache: &HashMap) -> Result<(), String> { @@ -219,13 +222,23 @@ fn get_local_api_token(webview: Webview, state: tauri::State<'_, LocalApiState>) } #[tauri::command] -fn get_desktop_runtime_info() -> DesktopRuntimeInfo { +fn get_desktop_runtime_info(state: tauri::State<'_, LocalApiState>) -> DesktopRuntimeInfo { + let port = state.port.lock().ok().and_then(|g| *g); DesktopRuntimeInfo { os: env::consts::OS.to_string(), arch: env::consts::ARCH.to_string(), + local_api_port: port, } } +#[tauri::command] +fn get_local_api_port(webview: Webview, state: tauri::State<'_, LocalApiState>) -> Result { + require_trusted_window(webview.label())?; + state.port.lock() + .map_err(|_| "Failed to lock port state".to_string())? + .ok_or_else(|| "Port not yet assigned".to_string()) +} + #[tauri::command] fn list_supported_secret_keys() -> Vec { SUPPORTED_SECRET_KEYS @@ -867,6 +880,18 @@ fn resolve_node_binary(app: &AppHandle) -> Option { common_locations.into_iter().find(|path| path.is_file()) } +fn probe_available_port(preferred: u16) -> u16 { + if let Ok(listener) = TcpListener::bind(("127.0.0.1", preferred)) { + drop(listener); + return preferred; + } + let listener = TcpListener::bind(("127.0.0.1", 0)) + .expect("no ephemeral port available"); + let port = listener.local_addr().unwrap().port(); + drop(listener); + port +} + fn start_local_api(app: &AppHandle) -> Result<(), String> { let state = app.state::(); let mut slot = state @@ -877,6 +902,11 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> { return Ok(()); } + // Clear port state for fresh start + if let Ok(mut port_slot) = state.port.lock() { + *port_slot = None; + } + let (script, resource_root) = local_api_paths(app); if !script.exists() { return Err(format!( @@ -888,6 +918,9 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> { "Node.js executable not found. Install Node 18+ or set LOCAL_API_NODE_BIN".to_string() })?; + // Probe for available port — fall back to OS-assigned if default is busy + let actual_port = probe_available_port(DEFAULT_LOCAL_API_PORT); + let log_path = sidecar_log_path(app)?; let log_file = OpenOptions::new() .create(true) @@ -913,6 +946,11 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> { "INFO", &format!("resolved node binary={}", node_binary.display()), ); + append_desktop_log( + app, + "INFO", + &format!("local API sidecar using port {actual_port}"), + ); // Generate a unique token for local API auth (prevents other local processes from accessing sidecar) let mut token_slot = state @@ -939,7 +977,7 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> { &format!("node args: script={script_for_node} resource_dir={resource_for_node}"), ); cmd.arg(&script_for_node) - .env("LOCAL_API_PORT", LOCAL_API_PORT) + .env("LOCAL_API_PORT", actual_port.to_string()) .env("LOCAL_API_RESOURCE_DIR", &resource_for_node) .env("LOCAL_API_MODE", "tauri-sidecar") .env("LOCAL_API_TOKEN", &local_api_token) @@ -980,6 +1018,12 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> { &format!("local API sidecar started pid={}", child.id()), ); *slot = Some(child); + + // Store the actual port after successful spawn + if let Ok(mut port_slot) = state.port.lock() { + *port_slot = Some(actual_port); + } + Ok(()) } @@ -991,6 +1035,9 @@ fn stop_local_api(app: &AppHandle) { append_desktop_log(app, "INFO", "local API sidecar stopped"); } } + if let Ok(mut port_slot) = state.port.lock() { + *port_slot = None; + } } } @@ -1087,6 +1134,7 @@ fn main() { set_secret, delete_secret, get_local_api_token, + get_local_api_port, get_desktop_runtime_info, read_cache_entry, write_cache_entry, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e2262d7f5..c287ebb73 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -29,7 +29,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:46123 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" + "csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" } }, "bundle": { diff --git a/src/components/LiveNewsPanel.ts b/src/components/LiveNewsPanel.ts index f17e87f62..e1936ec4e 100644 --- a/src/components/LiveNewsPanel.ts +++ b/src/components/LiveNewsPanel.ts @@ -1,6 +1,6 @@ import { Panel } from './Panel'; import { fetchLiveVideoId } from '@/services/live-news'; -import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime'; +import { isDesktopRuntime, getRemoteApiBaseUrl, getApiBaseUrl } from '@/services/runtime'; import { t } from '../services/i18n'; import { loadFromStorage, saveToStorage } from '@/utils'; import { STORAGE_KEYS, SITE_VARIANT } from '@/config'; @@ -316,7 +316,8 @@ export class LiveNewsPanel extends Panel { this.boundMessageHandler = (e: MessageEvent) => { if (e.source !== this.desktopEmbedIframe?.contentWindow) return; const expected = this.embedOrigin; - if (e.origin !== expected && e.origin !== 'http://127.0.0.1:46123') return; + const localOrigin = getApiBaseUrl(); + if (e.origin !== expected && (!localOrigin || e.origin !== localOrigin)) return; const msg = e.data; if (!msg || typeof msg !== 'object' || !msg.type) return; if (msg.type === 'yt-ready') { diff --git a/src/components/ServiceStatusPanel.ts b/src/components/ServiceStatusPanel.ts index 4f17384b3..44a941958 100644 --- a/src/components/ServiceStatusPanel.ts +++ b/src/components/ServiceStatusPanel.ts @@ -1,7 +1,7 @@ import { Panel } from './Panel'; import { t } from '@/services/i18n'; -import { isDesktopRuntime } from '@/services/runtime'; +import { getLocalApiPort, isDesktopRuntime } from '@/services/runtime'; import { getDesktopReadinessChecks, getKeyBackedAvailabilitySummary, @@ -131,7 +131,7 @@ export class ServiceStatusPanel extends Panel { ); } - const port = this.localBackend.port ?? 46123; + const port = this.localBackend.port ?? getLocalApiPort(); const remote = this.localBackend.remoteBase ?? 'https://worldmonitor.app'; return h('div', { className: 'service-status-backend' }, diff --git a/src/services/runtime-config.ts b/src/services/runtime-config.ts index 385ae7720..c17756e52 100644 --- a/src/services/runtime-config.ts +++ b/src/services/runtime-config.ts @@ -1,4 +1,4 @@ -import { isDesktopRuntime } from './runtime'; +import { getApiBaseUrl, isDesktopRuntime } from './runtime'; import { invokeTauri } from './tauri-bridge'; export type RuntimeSecretKey = @@ -63,8 +63,12 @@ export interface RuntimeConfig { } const TOGGLES_STORAGE_KEY = 'worldmonitor-runtime-feature-toggles'; -const SIDECAR_ENV_UPDATE_URL = 'http://127.0.0.1:46123/api/local-env-update'; -const SIDECAR_SECRET_VALIDATE_URL = 'http://127.0.0.1:46123/api/local-validate-secret'; +function getSidecarEnvUpdateUrl(): string { + return `${getApiBaseUrl()}/api/local-env-update`; +} +function getSidecarSecretValidateUrl(): string { + return `${getApiBaseUrl()}/api/local-validate-secret`; +} const defaultToggles: Record = { aiGroq: true, @@ -406,7 +410,7 @@ async function pushSecretToSidecar(key: string, value: string): Promise { headers.set('Authorization', `Bearer ${token}`); } - const response = await fetch(SIDECAR_ENV_UPDATE_URL, { + const response = await fetch(getSidecarEnvUpdateUrl(), { method: 'POST', headers, body: JSON.stringify({ key, value: value || null }), @@ -445,7 +449,7 @@ export async function verifySecretWithApi( } try { - const response = await callSidecarWithAuth(SIDECAR_SECRET_VALIDATE_URL, { + const response = await callSidecarWithAuth(getSidecarSecretValidateUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value: value.trim(), context }), diff --git a/src/services/runtime.ts b/src/services/runtime.ts index 9ad2bbd69..dcd904d38 100644 --- a/src/services/runtime.ts +++ b/src/services/runtime.ts @@ -4,9 +4,37 @@ const DEFAULT_REMOTE_HOSTS: Record = { world: 'https://worldmonitor.app', }; -const DEFAULT_LOCAL_API_BASE = 'http://127.0.0.1:46123'; +const DEFAULT_LOCAL_API_PORT = 46123; const FORCE_DESKTOP_RUNTIME = import.meta.env.VITE_DESKTOP_RUNTIME === '1'; +let _resolvedPort: number | null = null; +let _portPromise: Promise | null = null; + +export async function resolveLocalApiPort(): Promise { + if (_resolvedPort !== null) return _resolvedPort; + if (_portPromise) return _portPromise; + _portPromise = (async () => { + try { + const { tryInvokeTauri } = await import('@/services/tauri-bridge'); + const port = await tryInvokeTauri('get_local_api_port'); + if (port && port > 0) { + _resolvedPort = port; + return port; + } + } catch { + // IPC failed — allow retry on next call + } finally { + _portPromise = null; + } + return DEFAULT_LOCAL_API_PORT; + })(); + return _portPromise; +} + +export function getLocalApiPort(): number { + return _resolvedPort ?? DEFAULT_LOCAL_API_PORT; +} + function normalizeBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/$/, ''); } @@ -72,7 +100,7 @@ export function getApiBaseUrl(): string { return normalizeBaseUrl(configuredBaseUrl); } - return DEFAULT_LOCAL_API_BASE; + return `http://127.0.0.1:${getLocalApiPort()}`; } export function getRemoteApiBaseUrl(): string { @@ -213,7 +241,6 @@ export function installRuntimeFetchPatch(): void { } const nativeFetch = window.fetch.bind(window); - const localBase = getApiBaseUrl(); let localApiToken: string | null = null; let tokenFetchedAt = 0; @@ -229,6 +256,11 @@ export function installRuntimeFetchPatch(): void { return nativeFetch(input, init); } + // Resolve dynamic sidecar port on first API call + if (_resolvedPort === null) { + try { await resolveLocalApiPort(); } catch { /* use default */ } + } + const tokenExpired = localApiToken && (Date.now() - tokenFetchedAt > TOKEN_TTL_MS); if (!localApiToken || tokenExpired) { try { @@ -247,7 +279,7 @@ export function installRuntimeFetchPatch(): void { } const localInit = { ...init, headers }; - const localUrl = `${localBase}${target}`; + const localUrl = `${getApiBaseUrl()}${target}`; if (debug) console.log(`[fetch] intercept → ${target}`); let allowCloudFallback = !isLocalOnlyApiTarget(target); diff --git a/src/settings-main.ts b/src/settings-main.ts index dad9c0ed6..931f48b02 100644 --- a/src/settings-main.ts +++ b/src/settings-main.ts @@ -3,6 +3,7 @@ import './styles/settings-window.css'; import { RuntimeConfigPanel } from '@/components/RuntimeConfigPanel'; import { WorldMonitorTab } from '@/components/WorldMonitorTab'; import { RUNTIME_FEATURES, loadDesktopSecrets } from '@/services/runtime-config'; +import { getApiBaseUrl, resolveLocalApiPort } from '@/services/runtime'; import { tryInvokeTauri } from '@/services/tauri-bridge'; import { escapeHtml } from '@/utils/sanitize'; import { initI18n, t } from '@/services/i18n'; @@ -78,6 +79,11 @@ async function initSettingsWindow(): Promise { await initI18n(); applyStoredTheme(); + // Prime sidecar port before any diagnostics or sidecar calls. + // This sets _resolvedPort in runtime.ts so getApiBaseUrl() returns the + // correct port for all callers (including runtime-config.ts). + try { await resolveLocalApiPort(); } catch { /* use default */ } + requestAnimationFrame(() => { document.documentElement.classList.remove('no-transition'); }); @@ -167,7 +173,9 @@ async function initSettingsWindow(): Promise { initTabs(); } -const SIDECAR_BASE = 'http://127.0.0.1:46123'; +function getSidecarBase(): string { + return getApiBaseUrl() || 'http://127.0.0.1:46123'; +} function initDiagnostics(): void { const verboseToggle = document.getElementById('verboseApiLog') as HTMLInputElement | null; @@ -188,7 +196,7 @@ function initDiagnostics(): void { async function syncVerboseState(): Promise { if (!verboseToggle) return; try { - const res = await fetch(`${SIDECAR_BASE}/api/local-debug-toggle`); + const res = await fetch(`${getSidecarBase()}/api/local-debug-toggle`); const data = await res.json(); verboseToggle.checked = data.verboseMode; } catch { /* sidecar not running */ } @@ -196,7 +204,7 @@ function initDiagnostics(): void { verboseToggle?.addEventListener('change', async () => { try { - const res = await fetch(`${SIDECAR_BASE}/api/local-debug-toggle`, { method: 'POST' }); + const res = await fetch(`${getSidecarBase()}/api/local-debug-toggle`, { method: 'POST' }); const data = await res.json(); if (verboseToggle) verboseToggle.checked = data.verboseMode; setActionStatus(data.verboseMode ? t('modals.settingsWindow.verboseOn') : t('modals.settingsWindow.verboseOff'), 'ok'); @@ -210,7 +218,7 @@ function initDiagnostics(): void { async function refreshTrafficLog(): Promise { if (!trafficLogEl) return; try { - const res = await fetch(`${SIDECAR_BASE}/api/local-traffic-log`); + const res = await fetch(`${getSidecarBase()}/api/local-traffic-log`); const data = await res.json(); const entries: Array<{ timestamp: string; method: string; path: string; status: number; durationMs: number }> = data.entries || []; if (trafficCount) trafficCount.textContent = `(${entries.length})`; @@ -236,7 +244,7 @@ function initDiagnostics(): void { clearBtn?.addEventListener('click', async () => { try { - await fetch(`${SIDECAR_BASE}/api/local-traffic-log`, { method: 'DELETE' }); + await fetch(`${getSidecarBase()}/api/local-traffic-log`, { method: 'DELETE' }); } catch { /* ignore */ } if (trafficLogEl) trafficLogEl.innerHTML = `

${t('modals.settingsWindow.logCleared')}

`; if (trafficCount) trafficCount.textContent = '(0)'; From 2d78bae4e3cc630a1f6e2699ef41811e77ae816d Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Thu, 26 Feb 2026 08:48:58 +0400 Subject: [PATCH 2/3] fix: scope CSP to desktop builds and eliminate port TOCTOU race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: Remove http://127.0.0.1:* from index.html (web build CSP). The wildcard allowed web app JS to probe arbitrary localhost services. Vite's htmlVariantPlugin now injects localhost CSP only when VITE_DESKTOP_RUNTIME=1 (desktop builds). P2: Replace Rust probe_available_port() (bind→release→spawn race) with a confirmed port handshake. Sidecar now handles EADDRINUSE fallback internally and writes the actual bound port to a file. Rust polls the port file (up to 5s) to store only the confirmed port. --- index.html | 2 +- src-tauri/sidecar/local-api-server.mjs | 32 ++++++++----- src-tauri/src/main.rs | 63 +++++++++++++++++++------- src-tauri/tauri.conf.json | 2 +- vite.config.ts | 16 ++++++- 5 files changed, 84 insertions(+), 31 deletions(-) diff --git a/index.html b/index.html index 17b17fc44..b86db9c0a 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index 498f8d37f..ae11546d9 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -1224,23 +1224,33 @@ export async function createLocalApiServer(options = {}) { routes, server, async start() { - await new Promise((resolve, reject) => { - const onListening = () => { - server.off('error', onError); - resolve(); - }; - const onError = (error) => { - server.off('listening', onListening); - reject(error); - }; - + const tryListen = (port) => new Promise((resolve, reject) => { + const onListening = () => { server.off('error', onError); resolve(); }; + const onError = (error) => { server.off('listening', onListening); reject(error); }; server.once('listening', onListening); server.once('error', onError); - server.listen(context.port, '127.0.0.1'); + server.listen(port, '127.0.0.1'); }); + try { + await tryListen(context.port); + } catch (err) { + if (err?.code === 'EADDRINUSE') { + context.logger.log(`[local-api] port ${context.port} busy, falling back to OS-assigned port`); + await tryListen(0); + } else { + throw err; + } + } + const address = server.address(); const boundPort = typeof address === 'object' && address?.port ? address.port : context.port; + + const portFile = process.env.LOCAL_API_PORT_FILE; + if (portFile) { + try { writeFileSync(portFile, String(boundPort)); } catch {} + } + context.logger.log(`[local-api] listening on http://127.0.0.1:${boundPort} (apiDir=${context.apiDir}, routes=${routes.length}, cloudFallback=${context.cloudFallback})`); return { port: boundPort }; }, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e93482fcc..3d82a8dc3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,7 +4,6 @@ use std::collections::HashMap; use std::env; use std::fs::{self, File, OpenOptions}; use std::io::Write; -use std::net::TcpListener; #[cfg(windows)] use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; @@ -880,16 +879,21 @@ fn resolve_node_binary(app: &AppHandle) -> Option { common_locations.into_iter().find(|path| path.is_file()) } -fn probe_available_port(preferred: u16) -> u16 { - if let Ok(listener) = TcpListener::bind(("127.0.0.1", preferred)) { - drop(listener); - return preferred; +fn read_port_file(path: &Path, timeout_ms: u64) -> Option { + let start = std::time::Instant::now(); + let interval = std::time::Duration::from_millis(100); + let timeout = std::time::Duration::from_millis(timeout_ms); + while start.elapsed() < timeout { + if let Ok(contents) = fs::read_to_string(path) { + if let Ok(port) = contents.trim().parse::() { + if port > 0 { + return Some(port); + } + } + } + std::thread::sleep(interval); } - let listener = TcpListener::bind(("127.0.0.1", 0)) - .expect("no ephemeral port available"); - let port = listener.local_addr().unwrap().port(); - drop(listener); - port + None } fn start_local_api(app: &AppHandle) -> Result<(), String> { @@ -918,8 +922,8 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> { "Node.js executable not found. Install Node 18+ or set LOCAL_API_NODE_BIN".to_string() })?; - // Probe for available port — fall back to OS-assigned if default is busy - let actual_port = probe_available_port(DEFAULT_LOCAL_API_PORT); + let port_file = logs_dir_path(app)?.join("sidecar.port"); + let _ = fs::remove_file(&port_file); let log_path = sidecar_log_path(app)?; let log_file = OpenOptions::new() @@ -949,7 +953,11 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> { append_desktop_log( app, "INFO", - &format!("local API sidecar using port {actual_port}"), + &format!( + "local API sidecar preferred port={} port_file={}", + DEFAULT_LOCAL_API_PORT, + port_file.display() + ), ); // Generate a unique token for local API auth (prevents other local processes from accessing sidecar) @@ -977,7 +985,8 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> { &format!("node args: script={script_for_node} resource_dir={resource_for_node}"), ); cmd.arg(&script_for_node) - .env("LOCAL_API_PORT", actual_port.to_string()) + .env("LOCAL_API_PORT", DEFAULT_LOCAL_API_PORT.to_string()) + .env("LOCAL_API_PORT_FILE", &port_file) .env("LOCAL_API_RESOURCE_DIR", &resource_for_node) .env("LOCAL_API_MODE", "tauri-sidecar") .env("LOCAL_API_TOKEN", &local_api_token) @@ -1018,10 +1027,27 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> { &format!("local API sidecar started pid={}", child.id()), ); *slot = Some(child); + drop(slot); - // Store the actual port after successful spawn - if let Ok(mut port_slot) = state.port.lock() { - *port_slot = Some(actual_port); + // Wait for sidecar to write confirmed port (up to 5s) + if let Some(confirmed_port) = read_port_file(&port_file, 5000) { + append_desktop_log( + app, + "INFO", + &format!("sidecar confirmed port={confirmed_port}"), + ); + if let Ok(mut port_slot) = state.port.lock() { + *port_slot = Some(confirmed_port); + } + } else { + append_desktop_log( + app, + "WARN", + "sidecar port file not found within timeout, using default", + ); + if let Ok(mut port_slot) = state.port.lock() { + *port_slot = Some(DEFAULT_LOCAL_API_PORT); + } } Ok(()) @@ -1038,6 +1064,9 @@ fn stop_local_api(app: &AppHandle) { if let Ok(mut port_slot) = state.port.lock() { *port_slot = None; } + if let Ok(log_dir) = logs_dir_path(app) { + let _ = fs::remove_file(log_dir.join("sidecar.port")); + } } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c287ebb73..dd786b927 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -29,7 +29,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" + "csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:* https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" } }, "bundle": { diff --git a/vite.config.ts b/vite.config.ts index 85e539b64..25f9a6b06 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,7 @@ import { promisify } from 'util'; import pkg from './package.json'; const isE2E = process.env.VITE_E2E === '1'; - +const isDesktopBuild = process.env.VITE_DESKTOP_RUNTIME === '1'; const brotliCompressAsync = promisify(brotliCompress); const BROTLI_EXTENSIONS = new Set(['.js', '.mjs', '.css', '.html', '.svg', '.json', '.txt', '.xml', '.wasm']); @@ -189,6 +189,20 @@ function htmlVariantPlugin(): Plugin { ); } + // Desktop CSP: inject localhost wildcard for dynamic sidecar port. + // Web builds intentionally exclude localhost to avoid exposing attack surface. + if (isDesktopBuild) { + result = result + .replace( + /connect-src 'self' https: http:\/\/localhost:5173/, + "connect-src 'self' https: http://localhost:5173 http://127.0.0.1:*" + ) + .replace( + /frame-src 'self'/, + "frame-src 'self' http://127.0.0.1:*" + ); + } + // Favicon variant paths — replace /favico/ paths with variant-specific subdirectory if (activeVariant !== 'full') { result = result From 1abc13b617a35560971b9394cd61182ef03bc218 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Thu, 26 Feb 2026 08:50:27 +0400 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20isSafeUrl=20ReferenceError=20?= =?UTF-8?q?=E2=80=94=20addresses=20scoped=20inside=20try=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `let addresses = []` was declared inside the outer `try` block but referenced after the `catch` on line 200. `let` is block-scoped so every request through isSafeUrl crashed with: ReferenceError: addresses is not defined Move the declaration before the `try` so it's in scope for the return. --- src-tauri/sidecar/local-api-server.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index ae11546d9..382bc5bff 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -173,8 +173,8 @@ async function isSafeUrl(urlString) { // DNS resolution check — resolve the hostname and verify all resolved IPs // are public. This prevents DNS rebinding attacks where a public domain // resolves to a private IP. + let addresses = []; try { - let addresses = []; try { const v4 = await dns.resolve4(hostname); addresses = addresses.concat(v4);