From 09d92e6bf6eedaa0c4ec5604fcac03cd07650482 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 31 Jul 2026 14:12:10 -0700 Subject: [PATCH 1/6] Enable the desktop content security policy Restrict executable content to the packaged application while retaining the relay, media, asset, and Tauri IPC schemes the desktop uses at runtime. Co-authored-by: Jordan Mecom Signed-off-by: Jordan Mecom --- desktop/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 4bda55fd09..5dc1027276 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -36,7 +36,7 @@ ], "macOSPrivateApi": true, "security": { - "csp": null + "csp": "default-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https: http: wss: ws:; img-src 'self' asset: http://asset.localhost data: blob: https: http:; media-src 'self' asset: http://asset.localhost data: blob: https: http:; worker-src 'self' blob:" } }, "plugins": { From 5cbf16f89591c0d4d650caaabe1a7f54c134ea23 Mon Sep 17 00:00:00 2001 From: Eli Foster Date: Tue, 4 Aug 2026 11:39:24 -0700 Subject: [PATCH 2/6] fix(desktop): unblock wasm and media schemes in the CSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The newly enabled policy broke two shipped features in packaged builds, and neither `just dev` (loads the Vite devUrl) nor Playwright (`vite preview`) enforces the CSP, so nothing caught it: - `script-src 'self'` without `'wasm-unsafe-eval'` blocks WebAssembly instantiation. Shiki's default engine is Oniguruma-WASM (inlined, no fetch), so every code block silently fell back to plain text; MediaPipe selfie segmentation failed the same way. - The MediaPipe wasm loader is fetched from jsDelivr. Allowlist that origin rather than vendoring the 32MB asset set. - `rewriteRelayUrl` emits `buzz-media://localhost/...` until the loopback proxy port resolves, so cold-start media needs the custom scheme (mapped to `http://buzz-media.localhost` on Windows) in img/media/connect-src. Drops the `asset:` sources: `assetProtocol` is not enabled and nothing calls `convertFileSrc`. `connect-src` keeps blanket cleartext schemes — relay URLs are user-supplied and plain `ws://` on any host is accepted, so `relayProbe` would report reachable relays as dead. Adds csp_tests.rs pinning the non-obvious sources, since the policy is unenforceable in both local dev and the e2e suite. Signed-off-by: Eli Foster --- desktop/src-tauri/src/csp_tests.rs | 103 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/tauri.conf.json | 2 +- .../profile/lib/animatedAvatarCapture.ts | 4 +- 4 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 desktop/src-tauri/src/csp_tests.rs diff --git a/desktop/src-tauri/src/csp_tests.rs b/desktop/src-tauri/src/csp_tests.rs new file mode 100644 index 0000000000..d0c8725eb9 --- /dev/null +++ b/desktop/src-tauri/src/csp_tests.rs @@ -0,0 +1,103 @@ +//! Guards on the packaged-app Content-Security-Policy in `tauri.conf.json`. +//! +//! The CSP is only enforced on assets Tauri itself serves, so neither +//! `just dev` (loads the Vite `devUrl`) nor the Playwright suite (runs under +//! `vite preview`) can catch a policy that breaks the app. These tests pin the +//! non-obvious sources the frontend actually needs, so a future tightening +//! fails here instead of in a signed build. + +use std::collections::HashMap; + +const TAURI_CONF: &str = include_str!("../tauri.conf.json"); + +fn csp_directives() -> HashMap> { + let conf: serde_json::Value = + serde_json::from_str(TAURI_CONF).expect("tauri.conf.json is valid JSON"); + let csp = conf["app"]["security"]["csp"] + .as_str() + .expect("app.security.csp is set as a policy string"); + + csp.split(';') + .filter_map(|directive| { + let mut parts = directive.split_whitespace(); + let name = parts.next()?; + Some((name.to_owned(), parts.map(str::to_owned).collect())) + }) + .collect() +} + +fn sources(directive: &str) -> Vec { + csp_directives() + .remove(directive) + .unwrap_or_else(|| panic!("csp is missing the {directive} directive")) +} + +#[test] +fn script_src_allows_wasm_instantiation() { + // Shiki's default engine (Oniguruma) instantiates inlined WebAssembly for + // every code block; MediaPipe selfie segmentation does the same. Without + // this token both silently degrade — highlighting drops to plain text and + // animated avatars keep their background. + assert!(sources("script-src").contains(&"'wasm-unsafe-eval'".to_owned())); +} + +#[test] +fn script_src_allows_the_mediapipe_loader_cdn() { + // `animatedAvatarCapture.ts` loads the pinned tasks-vision wasm loader from + // jsDelivr. The 32MB asset set is too large to vendor, so the CDN origin + // stays allowlisted; self-hosting it would let this source be dropped. + assert!(sources("script-src").contains(&"https://cdn.jsdelivr.net".to_owned())); +} + +#[test] +fn media_directives_allow_the_buzz_media_scheme() { + // `rewriteRelayUrl` emits `buzz-media://localhost/...` until the loopback + // proxy port resolves, so cold-start media renders through the custom + // scheme (mapped to `http://buzz-media.localhost` on Windows). + for directive in ["img-src", "media-src", "connect-src"] { + let allowed = sources(directive); + assert!( + allowed.contains(&"buzz-media:".to_owned()), + "{directive} must allow buzz-media:" + ); + assert!( + allowed.contains(&"http://buzz-media.localhost".to_owned()), + "{directive} must allow http://buzz-media.localhost" + ); + } +} + +#[test] +fn connect_src_allows_ipc_and_cleartext_relays() { + // `ipc:` / `http://ipc.localhost` carry every Tauri command. Cleartext + // `http:`/`ws:` stay allowed because a relay URL is user-supplied and the + // app accepts plain `ws://` on any host (`communityStorage::normalizeRelayUrl`, + // the community edit form): `relayProbe` opens a browser WebSocket to it, + // so narrowing this to loopback would report reachable relays as dead — + // while the real connection, which runs through tauri-plugin-websocket in + // Rust, is not governed by CSP at all. Blanket `https:` is already allowed, + // so restricting the cleartext schemes would close no exfiltration path. + let allowed = sources("connect-src"); + for source in [ + "ipc:", + "http://ipc.localhost", + "https:", + "http:", + "wss:", + "ws:", + ] { + assert!( + allowed.contains(&source.to_owned()), + "connect-src must allow {source}" + ); + } +} + +#[test] +fn script_src_stays_free_of_unsafe_inline_and_eval() { + let allowed = sources("script-src"); + // The inline boot script in index.html is covered by Tauri's build-time + // sha256 hashing, so neither escape hatch is ever needed here. + assert!(!allowed.contains(&"'unsafe-inline'".to_owned())); + assert!(!allowed.contains(&"'unsafe-eval'".to_owned())); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4b733e3e0..0de97d902d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,6 +3,8 @@ mod app_state; mod archive; mod builderlab; mod commands; +#[cfg(test)] +mod csp_tests; mod deep_link; mod egress_guard; mod event_sync; diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 5dc1027276..5aba235986 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -36,7 +36,7 @@ ], "macOSPrivateApi": true, "security": { - "csp": "default-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https: http: wss: ws:; img-src 'self' asset: http://asset.localhost data: blob: https: http:; media-src 'self' asset: http://asset.localhost data: blob: https: http:; worker-src 'self' blob:" + "csp": "default-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost buzz-media: http://buzz-media.localhost https: http: wss: ws:; img-src 'self' buzz-media: http://buzz-media.localhost data: blob: https: http:; media-src 'self' buzz-media: http://buzz-media.localhost data: blob: https: http:; worker-src 'self' blob:" } }, "plugins": { diff --git a/desktop/src/features/profile/lib/animatedAvatarCapture.ts b/desktop/src/features/profile/lib/animatedAvatarCapture.ts index 3c906e1cbc..79775ce059 100644 --- a/desktop/src/features/profile/lib/animatedAvatarCapture.ts +++ b/desktop/src/features/profile/lib/animatedAvatarCapture.ts @@ -106,7 +106,9 @@ const PERSON_OUTLINE_OFFSETS = [ ] as const; // Pinned to the installed @mediapipe/tasks-vision version so the wasm loader -// always matches the JS API. +// always matches the JS API. This origin is allowlisted in `script-src` in +// tauri.conf.json — changing hosts (or self-hosting the wasm) needs the CSP +// updated in lockstep, or the packaged app silently loses segmentation. const MEDIAPIPE_WASM_BASE = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm"; const SELFIE_SEGMENTER_MODEL_URL = From e8bf348632b148590f59a1b99444c3a2ac832855 Mon Sep 17 00:00:00 2001 From: Eli Foster Date: Wed, 5 Aug 2026 14:41:17 -0700 Subject: [PATCH 3/6] fix(desktop): pin the MediaPipe loader URLs in script-src MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trusting the whole cdn.jsdelivr.net origin let any renderer injection that can append a