Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 13 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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**:
Expand Down
8 changes: 8 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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"}', {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,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",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -19,3 +19,4 @@ getrandom = "0.2"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
devtools = ["tauri/devtools"]
4 changes: 2 additions & 2 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
@@ -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 changes: 7 additions & 0 deletions src-tauri/capabilities/youtube-login.json
Original file line number Diff line number Diff line change
@@ -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"]
}
70 changes: 52 additions & 18 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,17 @@ 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";
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",
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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}"));
}
Expand All @@ -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}"));
}
Expand All @@ -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}"));
}
Expand All @@ -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);
Expand All @@ -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());
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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() {
Expand Down
31 changes: 29 additions & 2 deletions src/services/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand Down