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">
|
<p align="center">
|
||||||
<a href="https://worldmonitor.app">worldmonitor.app</a> ·
|
<a href="https://worldmonitor.app">worldmonitor.app</a> ·
|
||||||
<a href="https://tech.worldmonitor.app">tech.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
|
### 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
|
- No API keys should ever be committed to the repository
|
||||||
- Environment variables (`.env.local`) are gitignored
|
- Environment variables (`.env.local`) are gitignored
|
||||||
- The RSS proxy uses domain allowlisting to prevent SSRF
|
- 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
|
- No sensitive data is stored in localStorage or sessionStorage
|
||||||
- External content (RSS feeds, news) is sanitized before rendering
|
- External content (RSS feeds, news) is sanitized before rendering
|
||||||
- Map data layers use trusted, vetted data sources
|
- 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
|
### Data Sources
|
||||||
|
|
||||||
@@ -77,6 +87,8 @@ The following are **in scope** for security reports:
|
|||||||
- Edge function security issues (SSRF, injection, auth bypass)
|
- Edge function security issues (SSRF, injection, auth bypass)
|
||||||
- XSS or content injection through RSS feeds or external data
|
- XSS or content injection through RSS feeds or external data
|
||||||
- API key exposure or secret leakage
|
- 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
|
- Dependency vulnerabilities with a viable attack vector
|
||||||
|
|
||||||
The following are **out of scope**:
|
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']);
|
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
|
// Slack uses Slack-ImgProxy to fetch OG images — distinct from Slackbot
|
||||||
const SOCIAL_IMAGE_UA =
|
const SOCIAL_IMAGE_UA =
|
||||||
/Slack-ImgProxy|Slackbot|twitterbot|facebookexternalhit|linkedinbot|telegrambot|whatsapp|discordbot|redditbot/i;
|
/Slack-ImgProxy|Slackbot|twitterbot|facebookexternalhit|linkedinbot|telegrambot|whatsapp|discordbot|redditbot/i;
|
||||||
@@ -33,6 +36,11 @@ export default function middleware(request: Request) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public endpoints bypass all bot filtering
|
||||||
|
if (PUBLIC_API_PATHS.has(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Block bots from all API routes
|
// Block bots from all API routes
|
||||||
if (BOT_UA.test(ua)) {
|
if (BOT_UA.test(ua)) {
|
||||||
return new Response('{"error":"Forbidden"}', {
|
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: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: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",
|
"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: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: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",
|
"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]]
|
[[package]]
|
||||||
name = "world-monitor"
|
name = "world-monitor"
|
||||||
version = "2.5.6"
|
version = "2.5.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ edition = "2021"
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["devtools"] }
|
tauri = { version = "2", features = [] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native"] }
|
keyring = { version = "3", features = ["apple-native", "windows-native"] }
|
||||||
@@ -19,3 +19,4 @@ getrandom = "0.2"
|
|||||||
[features]
|
[features]
|
||||||
default = ["custom-protocol"]
|
default = ["custom-protocol"]
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
devtools = ["tauri/devtools"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capabilities for World Monitor main and settings windows",
|
"description": "Capabilities for World Monitor trusted app windows",
|
||||||
"windows": ["main", "settings", "live-channels", "youtube-login"],
|
"windows": ["main", "settings", "live-channels"],
|
||||||
"permissions": ["core:default"]
|
"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::Serialize;
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu};
|
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 LOCAL_API_PORT: &str = "46123";
|
||||||
const KEYRING_SERVICE: &str = "world-monitor";
|
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 DESKTOP_LOG_FILE: &str = "desktop.log";
|
||||||
const MENU_FILE_SETTINGS_ID: &str = "file.settings";
|
const MENU_FILE_SETTINGS_ID: &str = "file.settings";
|
||||||
const MENU_HELP_GITHUB_ID: &str = "help.github";
|
const MENU_HELP_GITHUB_ID: &str = "help.github";
|
||||||
|
#[cfg(feature = "devtools")]
|
||||||
const MENU_HELP_DEVTOOLS_ID: &str = "help.devtools";
|
const MENU_HELP_DEVTOOLS_ID: &str = "help.devtools";
|
||||||
|
const TRUSTED_WINDOWS: [&str; 3] = ["main", "settings", "live-channels"];
|
||||||
const SUPPORTED_SECRET_KEYS: [&str; 21] = [
|
const SUPPORTED_SECRET_KEYS: [&str; 21] = [
|
||||||
"GROQ_API_KEY",
|
"GROQ_API_KEY",
|
||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
@@ -195,8 +197,17 @@ fn generate_local_token() -> String {
|
|||||||
buf.iter().map(|b| format!("{b:02x}")).collect()
|
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]
|
#[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
|
let token = state
|
||||||
.token
|
.token
|
||||||
.lock()
|
.lock()
|
||||||
@@ -224,9 +235,11 @@ fn list_supported_secret_keys() -> Vec<String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_secret(
|
fn get_secret(
|
||||||
|
webview: Webview,
|
||||||
key: String,
|
key: String,
|
||||||
cache: tauri::State<'_, SecretsCache>,
|
cache: tauri::State<'_, SecretsCache>,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
|
require_trusted_window(webview.label())?;
|
||||||
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
|
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
|
||||||
return Err(format!("Unsupported secret key: {key}"));
|
return Err(format!("Unsupported secret key: {key}"));
|
||||||
}
|
}
|
||||||
@@ -238,20 +251,23 @@ fn get_secret(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_all_secrets(cache: tauri::State<'_, SecretsCache>) -> HashMap<String, String> {
|
fn get_all_secrets(webview: Webview, cache: tauri::State<'_, SecretsCache>) -> Result<HashMap<String, String>, String> {
|
||||||
cache
|
require_trusted_window(webview.label())?;
|
||||||
|
Ok(cache
|
||||||
.secrets
|
.secrets
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.clone()
|
.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_secret(
|
fn set_secret(
|
||||||
|
webview: Webview,
|
||||||
key: String,
|
key: String,
|
||||||
value: String,
|
value: String,
|
||||||
cache: tauri::State<'_, SecretsCache>,
|
cache: tauri::State<'_, SecretsCache>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
require_trusted_window(webview.label())?;
|
||||||
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
|
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
|
||||||
return Err(format!("Unsupported secret key: {key}"));
|
return Err(format!("Unsupported secret key: {key}"));
|
||||||
}
|
}
|
||||||
@@ -273,7 +289,8 @@ fn set_secret(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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()) {
|
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
|
||||||
return Err(format!("Unsupported secret key: {key}"));
|
return Err(format!("Unsupported secret key: {key}"));
|
||||||
}
|
}
|
||||||
@@ -299,12 +316,14 @@ fn cache_file_path(app: &AppHandle) -> Result<PathBuf, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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))
|
Ok(cache.get(&key))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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());
|
let mut data = cache.data.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
data.remove(&key);
|
data.remove(&key);
|
||||||
@@ -318,7 +337,8 @@ fn delete_cache_entry(cache: tauri::State<'_, PersistentCache>, key: String) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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)
|
let parsed_value: Value = serde_json::from_str(&value)
|
||||||
.map_err(|e| format!("Invalid cache payload JSON: {e}"))?;
|
.map_err(|e| format!("Invalid cache payload JSON: {e}"))?;
|
||||||
let _write_guard = cache.write_lock.lock().unwrap_or_else(|e| e.into_inner());
|
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).
|
/// 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.
|
/// Called from frontend when browser CORS and sidecar Node.js TLS both fail.
|
||||||
#[tauri::command]
|
#[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 allowed = ["events", "markets", "tags"];
|
||||||
let segment = path.trim_start_matches('/');
|
let segment = path.trim_start_matches('/');
|
||||||
if !allowed.iter().any(|a| segment.starts_with(a)) {
|
if !allowed.iter().any(|a| segment.starts_with(a)) {
|
||||||
@@ -641,6 +662,10 @@ fn build_app_menu(handle: &AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
|||||||
true,
|
true,
|
||||||
None::<&str>,
|
None::<&str>,
|
||||||
)?;
|
)?;
|
||||||
|
let help_separator = PredefinedMenuItem::separator(handle)?;
|
||||||
|
|
||||||
|
#[cfg(feature = "devtools")]
|
||||||
|
let help_menu = {
|
||||||
let devtools_item = MenuItem::with_id(
|
let devtools_item = MenuItem::with_id(
|
||||||
handle,
|
handle,
|
||||||
MENU_HELP_DEVTOOLS_ID,
|
MENU_HELP_DEVTOOLS_ID,
|
||||||
@@ -648,12 +673,20 @@ fn build_app_menu(handle: &AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
|||||||
true,
|
true,
|
||||||
Some("CmdOrCtrl+Alt+I"),
|
Some("CmdOrCtrl+Alt+I"),
|
||||||
)?;
|
)?;
|
||||||
let help_separator = PredefinedMenuItem::separator(handle)?;
|
Submenu::with_items(
|
||||||
let help_menu = Submenu::with_items(
|
|
||||||
handle,
|
handle,
|
||||||
"Help",
|
"Help",
|
||||||
true,
|
true,
|
||||||
&[&about_item, &help_separator, &github_item, &devtools_item],
|
&[&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],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let edit_menu = {
|
let edit_menu = {
|
||||||
@@ -686,6 +719,7 @@ fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
|
|||||||
MENU_HELP_GITHUB_ID => {
|
MENU_HELP_GITHUB_ID => {
|
||||||
let _ = open_in_shell("https://github.com/koala73/worldmonitor");
|
let _ = open_in_shell("https://github.com/koala73/worldmonitor");
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "devtools")]
|
||||||
MENU_HELP_DEVTOOLS_ID => {
|
MENU_HELP_DEVTOOLS_ID => {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
if window.is_devtools_open() {
|
if window.is_devtools_open() {
|
||||||
|
|||||||
@@ -186,6 +186,27 @@ async function fetchLocalWithStartupRetry(
|
|||||||
: new Error('Local API unavailable');
|
: 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 {
|
export function installRuntimeFetchPatch(): void {
|
||||||
if (!isDesktopRuntime() || typeof window === 'undefined' || (window as unknown as Record<string, unknown>).__wmFetchPatched) {
|
if (!isDesktopRuntime() || typeof window === 'undefined' || (window as unknown as Record<string, unknown>).__wmFetchPatched) {
|
||||||
return;
|
return;
|
||||||
@@ -194,6 +215,7 @@ export function installRuntimeFetchPatch(): void {
|
|||||||
const nativeFetch = window.fetch.bind(window);
|
const nativeFetch = window.fetch.bind(window);
|
||||||
const localBase = getApiBaseUrl();
|
const localBase = getApiBaseUrl();
|
||||||
let localApiToken: string | null = null;
|
let localApiToken: string | null = null;
|
||||||
|
let tokenFetchedAt = 0;
|
||||||
|
|
||||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
const target = getApiTargetFromRequestInput(input);
|
const target = getApiTargetFromRequestInput(input);
|
||||||
@@ -207,11 +229,16 @@ export function installRuntimeFetchPatch(): void {
|
|||||||
return nativeFetch(input, init);
|
return nativeFetch(input, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!localApiToken) {
|
const tokenExpired = localApiToken && (Date.now() - tokenFetchedAt > TOKEN_TTL_MS);
|
||||||
|
if (!localApiToken || tokenExpired) {
|
||||||
try {
|
try {
|
||||||
const { tryInvokeTauri } = await import('@/services/tauri-bridge');
|
const { tryInvokeTauri } = await import('@/services/tauri-bridge');
|
||||||
localApiToken = await tryInvokeTauri<string>('get_local_api_token');
|
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);
|
const headers = new Headers(init?.headers);
|
||||||
|
|||||||
Reference in New Issue
Block a user