mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
security: harden IPC, gate DevTools, isolate external windows, exempt /api/version (#348)
* security: harden IPC commands, gate DevTools, and isolate external windows - Remove devtools from default Tauri features; gate behind opt-in Cargo feature so production builds never expose DevTools - Add IPC origin validation (require_trusted_window) to 9 sensitive commands: get_secret, get_all_secrets, set_secret, delete_secret, get_local_api_token, read/write/delete_cache_entry, fetch_polymarket - Isolate youtube-login window into restricted capability (core:window only) — prevents external-origin webview from invoking app commands - Add 5-minute TTL to cached sidecar auth token in fetch patch closure - Document renderer trust boundary threat model in runtime.ts * docs: add contributors, security acknowledgments, and desktop security policy - Add Contributors section to README with all 16 GitHub contributors - Add Security Acknowledgments crediting Cody Richard for 3 disclosures - Update SECURITY.md with desktop runtime security model (Tauri IPC origin validation, DevTools gating, sidecar auth, capability isolation, fetch patch trust boundary) - Add Tauri-specific items to security report scope - Correct API key storage description to cover both web and desktop * fix: exempt /api/version from bot-blocking middleware The desktop update check and sidecar requests were getting 403'd by the middleware's bot UA filter (curl/) and short UA check.
This commit is contained in:
33
README.md
33
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.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://worldmonitor.app">worldmonitor.app</a> ·
|
||||
<a href="https://tech.worldmonitor.app">tech.worldmonitor.app</a> ·
|
||||
|
||||
14
SECURITY.md
14
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**:
|
||||
|
||||
@@ -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"}', {
|
||||
|
||||
@@ -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",
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -4890,7 +4890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "world-monitor"
|
||||
version = "2.5.6"
|
||||
version = "2.5.7"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"keyring",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
7
src-tauri/capabilities/youtube-login.json
Normal file
7
src-tauri/capabilities/youtube-login.json
Normal file
@@ -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"]
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
fn get_local_api_token(webview: Webview, state: tauri::State<'_, LocalApiState>) -> Result<String, String> {
|
||||
require_trusted_window(webview.label())?;
|
||||
let token = state
|
||||
.token
|
||||
.lock()
|
||||
@@ -224,9 +235,11 @@ fn list_supported_secret_keys() -> Vec<String> {
|
||||
|
||||
#[tauri::command]
|
||||
fn get_secret(
|
||||
webview: Webview,
|
||||
key: String,
|
||||
cache: tauri::State<'_, SecretsCache>,
|
||||
) -> Result<Option<String>, 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<String, String> {
|
||||
cache
|
||||
fn get_all_secrets(webview: Webview, cache: tauri::State<'_, SecretsCache>) -> Result<HashMap<String, String>, 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<PathBuf, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_cache_entry(cache: tauri::State<'_, PersistentCache>, key: String) -> Result<Option<Value>, String> {
|
||||
fn read_cache_entry(webview: Webview, cache: tauri::State<'_, PersistentCache>, key: String) -> Result<Option<Value>, 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<String, String> {
|
||||
async fn fetch_polymarket(webview: Webview, path: String, params: String) -> Result<String, String> {
|
||||
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<Menu<tauri::Wry>> {
|
||||
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() {
|
||||
|
||||
@@ -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<string, unknown>).__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<Response> => {
|
||||
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<string>('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);
|
||||
|
||||
Reference in New Issue
Block a user