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:
Elie Habib
2026-02-25 06:14:16 +00:00
committed by GitHub
parent b1d835b69f
commit 408d5d3374
10 changed files with 148 additions and 26 deletions

View File

@@ -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> &nbsp;·&nbsp;
<a href="https://tech.worldmonitor.app">tech.worldmonitor.app</a> &nbsp;·&nbsp;

View File

@@ -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**:

View File

@@ -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"}', {

View File

@@ -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
View File

@@ -4890,7 +4890,7 @@ dependencies = [
[[package]]
name = "world-monitor"
version = "2.5.6"
version = "2.5.7"
dependencies = [
"getrandom 0.2.17",
"keyring",

View File

@@ -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"]

View File

@@ -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"]
}

View 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"]
}

View File

@@ -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() {

View File

@@ -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);