diff --git a/README.md b/README.md index 9cac0e393..39e5e569d 100644 --- a/README.md +++ b/README.md @@ -1479,6 +1479,39 @@ GNU Affero General Public License v3.0 (AGPL-3.0) — see [LICENSE](LICENSE) for --- +## Contributors + +Thanks to everyone who has contributed to World Monitor: + +[@SebastienMelki](https://github.com/SebastienMelki), +[@Lib-LOCALE](https://github.com/Lib-LOCALE), +[@lawyered0](https://github.com/lawyered0), +[@elzalem](https://github.com/elzalem), +[@Rau1CS](https://github.com/Rau1CS), +[@Sethispr](https://github.com/Sethispr), +[@InlitX](https://github.com/InlitX), +[@Ahmadhamdan47](https://github.com/Ahmadhamdan47), +[@K35P](https://github.com/K35P), +[@Niboshi-Wasabi](https://github.com/Niboshi-Wasabi), +[@pedroddomingues](https://github.com/pedroddomingues), +[@haosenwang1018](https://github.com/haosenwang1018), +[@aa5064](https://github.com/aa5064), +[@cwnicoletti](https://github.com/cwnicoletti), +[@facusturla](https://github.com/facusturla), +[@toasterbook88](https://github.com/toasterbook88) + +--- + +## Security Acknowledgments + +We thank the following researchers for responsibly disclosing security issues: + +- **Cody Richard** — Disclosed three security findings covering IPC command exposure via DevTools in production builds, renderer-to-sidecar trust boundary analysis, and the global fetch patch credential injection architecture (2025) + +If you discover a vulnerability, please see our [Security Policy](./SECURITY.md) for responsible disclosure guidelines. + +--- +

worldmonitor.app  ·  tech.worldmonitor.app  ·  diff --git a/SECURITY.md b/SECURITY.md index f28241d48..cff890e27 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,7 +44,8 @@ World Monitor is a client-side intelligence dashboard that aggregates publicly a ### API Keys & Secrets -- All API keys are stored server-side in Vercel Edge Functions +- **Web deployment**: API keys are stored server-side in Vercel Edge Functions +- **Desktop runtime**: API keys are stored in the OS keychain (macOS Keychain / Windows Credential Manager) via a consolidated vault entry, never on disk in plaintext - No API keys should ever be committed to the repository - Environment variables (`.env.local`) are gitignored - The RSS proxy uses domain allowlisting to prevent SSRF @@ -61,6 +62,15 @@ World Monitor is a client-side intelligence dashboard that aggregates publicly a - No sensitive data is stored in localStorage or sessionStorage - External content (RSS feeds, news) is sanitized before rendering - Map data layers use trusted, vetted data sources +- Content Security Policy restricts script-src to `'self'` (no unsafe-inline/eval) + +### Desktop Runtime Security (Tauri) + +- **IPC origin validation**: Sensitive Tauri commands (secrets, cache, token) are gated to trusted windows only; external-origin windows (e.g., YouTube login) are blocked +- **DevTools**: Disabled in production builds; gated behind an opt-in Cargo feature for development +- **Sidecar authentication**: A per-session CSPRNG token (`LOCAL_API_TOKEN`) authenticates all renderer-to-sidecar requests, preventing other local processes from accessing the API +- **Capability isolation**: The YouTube login window runs under a restricted capability with no access to secret or cache IPC commands +- **Fetch patch trust boundary**: The global fetch interceptor injects the sidecar token with a 5-minute TTL; the renderer is the intended client — if renderer integrity is compromised, Tauri IPC provides strictly more access than the fetch patch ### Data Sources @@ -77,6 +87,8 @@ The following are **in scope** for security reports: - Edge function security issues (SSRF, injection, auth bypass) - XSS or content injection through RSS feeds or external data - API key exposure or secret leakage +- Tauri IPC command privilege escalation or capability bypass +- Sidecar authentication bypass or token leakage - Dependency vulnerabilities with a viable attack vector The following are **out of scope**: diff --git a/middleware.ts b/middleware.ts index 04c8d1efe..36fdf1ea5 100644 --- a/middleware.ts +++ b/middleware.ts @@ -12,6 +12,9 @@ const SOCIAL_PREVIEW_UA = const SOCIAL_PREVIEW_PATHS = new Set(['/api/story', '/api/og-story']); +// Public endpoints that should never be bot-blocked (version check, etc.) +const PUBLIC_API_PATHS = new Set(['/api/version']); + // Slack uses Slack-ImgProxy to fetch OG images — distinct from Slackbot const SOCIAL_IMAGE_UA = /Slack-ImgProxy|Slackbot|twitterbot|facebookexternalhit|linkedinbot|telegrambot|whatsapp|discordbot|redditbot/i; @@ -33,6 +36,11 @@ export default function middleware(request: Request) { return; } + // Public endpoints bypass all bot filtering + if (PUBLIC_API_PATHS.has(path)) { + return; + } + // Block bots from all API routes if (BOT_UA.test(ua)) { return new Response('{"error":"Forbidden"}', { diff --git a/package.json b/package.json index 217dc16b5..b9208879a 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "test:e2e:visual:update:full": "VITE_VARIANT=full playwright test -g \"matches golden screenshots per layer and zoom\" --update-snapshots", "test:e2e:visual:update:tech": "VITE_VARIANT=tech playwright test -g \"matches golden screenshots per layer and zoom\" --update-snapshots", "test:e2e:visual:update": "npm run test:e2e:visual:update:full && npm run test:e2e:visual:update:tech", - "desktop:dev": "npm run version:sync && VITE_DESKTOP_RUNTIME=1 tauri dev", + "desktop:dev": "npm run version:sync && VITE_DESKTOP_RUNTIME=1 tauri dev -f devtools", "desktop:build:full": "npm run version:sync && VITE_VARIANT=full VITE_DESKTOP_RUNTIME=1 tauri build", "desktop:build:tech": "npm run version:sync && VITE_VARIANT=tech VITE_DESKTOP_RUNTIME=1 tauri build --config src-tauri/tauri.tech.conf.json", "desktop:build:finance": "npm run version:sync && VITE_VARIANT=finance VITE_DESKTOP_RUNTIME=1 tauri build --config src-tauri/tauri.finance.conf.json", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8919cff1c..b63102756 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4890,7 +4890,7 @@ dependencies = [ [[package]] name = "world-monitor" -version = "2.5.6" +version = "2.5.7" dependencies = [ "getrandom 0.2.17", "keyring", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 96e63e35e..5411be19d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["devtools"] } +tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" keyring = { version = "3", features = ["apple-native", "windows-native"] } @@ -19,3 +19,4 @@ getrandom = "0.2" [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] +devtools = ["tauri/devtools"] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 10dfb0aea..608b89cfa 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capabilities for World Monitor main and settings windows", - "windows": ["main", "settings", "live-channels", "youtube-login"], + "description": "Capabilities for World Monitor trusted app windows", + "windows": ["main", "settings", "live-channels"], "permissions": ["core:default"] } diff --git a/src-tauri/capabilities/youtube-login.json b/src-tauri/capabilities/youtube-login.json new file mode 100644 index 000000000..0a25e8b68 --- /dev/null +++ b/src-tauri/capabilities/youtube-login.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "youtube-login", + "description": "Restricted capabilities for the external-origin YouTube login window", + "windows": ["youtube-login"], + "permissions": ["core:window:default"] +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 501ac73f2..2552fbf0a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -16,7 +16,7 @@ use reqwest::Url; use serde::Serialize; use serde_json::{Map, Value}; use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; -use tauri::{AppHandle, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent}; +use tauri::{AppHandle, Manager, RunEvent, Webview, WebviewUrl, WebviewWindowBuilder, WindowEvent}; const LOCAL_API_PORT: &str = "46123"; const KEYRING_SERVICE: &str = "world-monitor"; @@ -24,7 +24,9 @@ const LOCAL_API_LOG_FILE: &str = "local-api.log"; const DESKTOP_LOG_FILE: &str = "desktop.log"; const MENU_FILE_SETTINGS_ID: &str = "file.settings"; const MENU_HELP_GITHUB_ID: &str = "help.github"; +#[cfg(feature = "devtools")] const MENU_HELP_DEVTOOLS_ID: &str = "help.devtools"; +const TRUSTED_WINDOWS: [&str; 3] = ["main", "settings", "live-channels"]; const SUPPORTED_SECRET_KEYS: [&str; 21] = [ "GROQ_API_KEY", "OPENROUTER_API_KEY", @@ -195,8 +197,17 @@ fn generate_local_token() -> String { buf.iter().map(|b| format!("{b:02x}")).collect() } +fn require_trusted_window(label: &str) -> Result<(), String> { + if TRUSTED_WINDOWS.contains(&label) { + Ok(()) + } else { + Err(format!("Command not allowed from window '{label}'")) + } +} + #[tauri::command] -fn get_local_api_token(state: tauri::State<'_, LocalApiState>) -> Result { +fn get_local_api_token(webview: Webview, state: tauri::State<'_, LocalApiState>) -> Result { + require_trusted_window(webview.label())?; let token = state .token .lock() @@ -224,9 +235,11 @@ fn list_supported_secret_keys() -> Vec { #[tauri::command] fn get_secret( + webview: Webview, key: String, cache: tauri::State<'_, SecretsCache>, ) -> Result, String> { + require_trusted_window(webview.label())?; if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) { return Err(format!("Unsupported secret key: {key}")); } @@ -238,20 +251,23 @@ fn get_secret( } #[tauri::command] -fn get_all_secrets(cache: tauri::State<'_, SecretsCache>) -> HashMap { - cache +fn get_all_secrets(webview: Webview, cache: tauri::State<'_, SecretsCache>) -> Result, String> { + require_trusted_window(webview.label())?; + Ok(cache .secrets .lock() .unwrap_or_else(|e| e.into_inner()) - .clone() + .clone()) } #[tauri::command] fn set_secret( + webview: Webview, key: String, value: String, cache: tauri::State<'_, SecretsCache>, ) -> Result<(), String> { + require_trusted_window(webview.label())?; if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) { return Err(format!("Unsupported secret key: {key}")); } @@ -273,7 +289,8 @@ fn set_secret( } #[tauri::command] -fn delete_secret(key: String, cache: tauri::State<'_, SecretsCache>) -> Result<(), String> { +fn delete_secret(webview: Webview, key: String, cache: tauri::State<'_, SecretsCache>) -> Result<(), String> { + require_trusted_window(webview.label())?; if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) { return Err(format!("Unsupported secret key: {key}")); } @@ -299,12 +316,14 @@ fn cache_file_path(app: &AppHandle) -> Result { } #[tauri::command] -fn read_cache_entry(cache: tauri::State<'_, PersistentCache>, key: String) -> Result, String> { +fn read_cache_entry(webview: Webview, cache: tauri::State<'_, PersistentCache>, key: String) -> Result, String> { + require_trusted_window(webview.label())?; Ok(cache.get(&key)) } #[tauri::command] -fn delete_cache_entry(cache: tauri::State<'_, PersistentCache>, key: String) -> Result<(), String> { +fn delete_cache_entry(webview: Webview, cache: tauri::State<'_, PersistentCache>, key: String) -> Result<(), String> { + require_trusted_window(webview.label())?; { let mut data = cache.data.lock().unwrap_or_else(|e| e.into_inner()); data.remove(&key); @@ -318,7 +337,8 @@ fn delete_cache_entry(cache: tauri::State<'_, PersistentCache>, key: String) -> } #[tauri::command] -fn write_cache_entry(app: AppHandle, cache: tauri::State<'_, PersistentCache>, key: String, value: String) -> Result<(), String> { +fn write_cache_entry(webview: Webview, app: AppHandle, cache: tauri::State<'_, PersistentCache>, key: String, value: String) -> Result<(), String> { + require_trusted_window(webview.label())?; let parsed_value: Value = serde_json::from_str(&value) .map_err(|e| format!("Invalid cache payload JSON: {e}"))?; let _write_guard = cache.write_lock.lock().unwrap_or_else(|e| e.into_inner()); @@ -488,7 +508,8 @@ fn close_live_channels_window(app: AppHandle) -> Result<(), String> { /// Fetch JSON from Polymarket Gamma API using native TLS (bypasses Cloudflare JA3 blocking). /// Called from frontend when browser CORS and sidecar Node.js TLS both fail. #[tauri::command] -async fn fetch_polymarket(path: String, params: String) -> Result { +async fn fetch_polymarket(webview: Webview, path: String, params: String) -> Result { + require_trusted_window(webview.label())?; let allowed = ["events", "markets", "tags"]; let segment = path.trim_start_matches('/'); if !allowed.iter().any(|a| segment.starts_with(a)) { @@ -641,19 +662,31 @@ fn build_app_menu(handle: &AppHandle) -> tauri::Result> { true, None::<&str>, )?; - let devtools_item = MenuItem::with_id( - handle, - MENU_HELP_DEVTOOLS_ID, - "Toggle Developer Tools", - true, - Some("CmdOrCtrl+Alt+I"), - )?; let help_separator = PredefinedMenuItem::separator(handle)?; + + #[cfg(feature = "devtools")] + let help_menu = { + let devtools_item = MenuItem::with_id( + handle, + MENU_HELP_DEVTOOLS_ID, + "Toggle Developer Tools", + true, + Some("CmdOrCtrl+Alt+I"), + )?; + Submenu::with_items( + handle, + "Help", + true, + &[&about_item, &help_separator, &github_item, &devtools_item], + )? + }; + + #[cfg(not(feature = "devtools"))] let help_menu = Submenu::with_items( handle, "Help", true, - &[&about_item, &help_separator, &github_item, &devtools_item], + &[&about_item, &help_separator, &github_item], )?; let edit_menu = { @@ -686,6 +719,7 @@ fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) { MENU_HELP_GITHUB_ID => { let _ = open_in_shell("https://github.com/koala73/worldmonitor"); } + #[cfg(feature = "devtools")] MENU_HELP_DEVTOOLS_ID => { if let Some(window) = app.get_webview_window("main") { if window.is_devtools_open() { diff --git a/src/services/runtime.ts b/src/services/runtime.ts index 886f1b0cb..9ad2bbd69 100644 --- a/src/services/runtime.ts +++ b/src/services/runtime.ts @@ -186,6 +186,27 @@ async function fetchLocalWithStartupRetry( : new Error('Local API unavailable'); } +// ── Security threat model for the fetch patch ────────────────────────── +// The LOCAL_API_TOKEN exists to prevent OTHER local processes from +// accessing the sidecar on port 46123. The renderer IS the intended +// client — injecting the token automatically is correct by design. +// +// If the renderer is compromised (XSS, supply chain), the attacker +// already has access to strictly more powerful Tauri IPC commands +// (get_all_secrets, set_secret, etc.) via window.__TAURI_INTERNALS__. +// The fetch patch does not expand the attack surface beyond what IPC +// already provides. +// +// Defense layers that protect the renderer trust boundary: +// 1. CSP: script-src 'self' (no unsafe-inline/eval) +// 2. IPC origin validation: sensitive commands gated to trusted windows +// 3. Sidecar allowlists: env-update restricted to ALLOWED_ENV_KEYS +// 4. DevTools disabled in production builds +// +// The token has a 5-minute TTL in the closure to limit exposure window +// if IPC access is revoked mid-session. +const TOKEN_TTL_MS = 5 * 60 * 1000; + export function installRuntimeFetchPatch(): void { if (!isDesktopRuntime() || typeof window === 'undefined' || (window as unknown as Record).__wmFetchPatched) { return; @@ -194,6 +215,7 @@ export function installRuntimeFetchPatch(): void { const nativeFetch = window.fetch.bind(window); const localBase = getApiBaseUrl(); let localApiToken: string | null = null; + let tokenFetchedAt = 0; window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { const target = getApiTargetFromRequestInput(input); @@ -207,11 +229,16 @@ export function installRuntimeFetchPatch(): void { return nativeFetch(input, init); } - if (!localApiToken) { + const tokenExpired = localApiToken && (Date.now() - tokenFetchedAt > TOKEN_TTL_MS); + if (!localApiToken || tokenExpired) { try { const { tryInvokeTauri } = await import('@/services/tauri-bridge'); localApiToken = await tryInvokeTauri('get_local_api_token'); - } catch { /* token unavailable — sidecar may not require it */ } + tokenFetchedAt = Date.now(); + } catch { + localApiToken = null; + tokenFetchedAt = 0; + } } const headers = new Headers(init?.headers);