diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 0d4417a754..39950d5902 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' 'wasm-unsafe-eval' https://cdn.jsdelivr.net/npm/@mediapipe/; 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-tauri/tests/csp.rs b/desktop/src-tauri/tests/csp.rs new file mode 100644 index 0000000000..a8cc880e41 --- /dev/null +++ b/desktop/src-tauri/tests/csp.rs @@ -0,0 +1,201 @@ +//! 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. +//! +//! Kept as an integration test so the policy can be checked without the app +//! crate having to declare a test-only module. + +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())); +} + +/// The `MEDIAPIPE_WASM_BASE` literal the frontend hands to `FilesetResolver`. +fn mediapipe_wasm_base() -> String { + const CAPTURE: &str = include_str!("../../src/features/profile/lib/animatedAvatarCapture.ts"); + + let after = CAPTURE + .split_once("const MEDIAPIPE_WASM_BASE =") + .expect("animatedAvatarCapture.ts declares MEDIAPIPE_WASM_BASE") + .1; + let url = after + .split_once('"') + .expect("MEDIAPIPE_WASM_BASE is a double-quoted string literal") + .1; + url.split_once('"') + .expect("MEDIAPIPE_WASM_BASE literal is terminated") + .0 + .to_owned() +} + +/// The npm scope the MediaPipe loader must come from. A CSP source ending in +/// `/` is a path *prefix* — paths can't be wildcarded — so this admits any +/// `@mediapipe` package while excluding the rest of what jsDelivr serves. +const MEDIAPIPE_SCOPE: &str = "https://cdn.jsdelivr.net/npm/@mediapipe/"; + +#[test] +fn script_src_scopes_the_mediapipe_loader() { + // `FilesetResolver.forVisionTasks` loads `vision_wasm[_nosimd]_internal.js` + // via a `