test(desktop): move CSP guards from Rust to the desktop JS suite (#8172)

## Summary

Moves the packaged-app CSP guards from `desktop/src-tauri/tests/csp.rs`
to `desktop/src/app/contentSecurityPolicy.test.mjs`. No production code
changes beyond one added `export`.

`csp.rs` never touched Rust app code. All seven tests parse
`tauri.conf.json` and compare CSP strings. The MediaPipe check also
`include_str!`'d
`desktop/src/features/profile/lib/animatedAvatarCapture.ts` and pulled
the `MEDIAPIPE_WASM_BASE` literal out by splitting the text. That made a
Tauri test depend on a frontend file. With path-filtered CI (#8156), a
change to only that `.ts` file would skip the check.

The JS version imports `MEDIAPIPE_WASM_BASE` directly (it's now
exported) and reads `src-tauri/tauri.conf.json` from disk. The
`desktop/**` CI filter covers both files, so the check runs in Desktop
JS whenever either one changes, and the filter needs no cross-boundary
special case.

The seven tests and their assertions carry over one-for-one, along with
their rationale comments.

**What got simpler:** we delete a 204-line Rust integration test, the
`include_str!` of a frontend file, and the hand-rolled string-literal
scraper, and the CI path filter doesn't need to know about this file.

### Related issue

Addresses the CSP coupling raised in review of #8156. Related: #4614
(introduced the guards).

### Testing

- New suite on Linux: 7/7 pass.
- I falsified each test by mutating the real policy or code, one
mutation at a time. Each mutation fails exactly the matching test, and
the suite passes again once restored:
  - moved `MEDIAPIPE_WASM_BASE` outside `@mediapipe/`
  - dropped `'wasm-unsafe-eval'`
  - dropped the MediaPipe scope from `script-src`
  - added a bare `https://cdn.jsdelivr.net/` origin
  - added `'unsafe-inline'`
  - removed `img-src`
  - dropped `ws:` from `connect-src`
  - made the pinned-source predicate always pass
- Blox `just ci` at tree `5664c6ce` (same tree as head `f798f94b`):
- Passed: fmt, clippy, desktop/Tauri/web checks,
security-review/file-size checks, `desktop-test` (6,819 + 139 jsdom),
`desktop-build`, `desktop-tauri-check`, `desktop-tauri-test`,
`web-build`.
- `test-unit`: 94/95 suites pass. The two failing `buzz-acp`
`git_bootstrap` tests fail because the workstation's Git 2.43 predates
the credential `authtype` protocol. They pass 4/4 with Git 2.51, and
this PR touches no `crates/`.
- Not run: `mobile-check`/`mobile-test`, because the workstation can't
reach pub.dev. No `mobile/` changes.
- Hosted CI green:
https://github.com/block/buzz/actions/runs/37801901659. This PR selects
the Tauri lanes because it deletes `src-tauri/tests/csp.rs`. Later edits
that only touch the constant select Desktop JS only.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
This commit is contained in:
Luke Tornquist
2026-10-09 15:23:27 -04:00
committed by GitHub
parent 99924a5a69
commit 5142cf6b2a
3 changed files with 184 additions and 206 deletions

View File

@@ -1,204 +0,0 @@
//! 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<String, Vec<String>> {
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<String> {
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 `<script>` tag (which of the two depends on a runtime SIMD probe),
// so the loader URL the frontend builds has to fall inside the allowlisted
// prefix — checked here rather than discovered in a signed build.
let allowed = sources("script-src");
assert!(
allowed.contains(&MEDIAPIPE_SCOPE.to_owned()),
"script-src must allow {MEDIAPIPE_SCOPE}"
);
let base = mediapipe_wasm_base();
assert!(
base.starts_with(MEDIAPIPE_SCOPE),
"MEDIAPIPE_WASM_BASE ({base}) must sit under the allowlisted {MEDIAPIPE_SCOPE}"
);
}
/// How many path segments a source narrows to past its origin.
fn path_depth(source: &str) -> usize {
let Some((_scheme, authority)) = source.split_once("://") else {
return 0;
};
match authority.split_once('/') {
Some((_host, path)) => path.split('/').filter(|part| !part.is_empty()).count(),
None => 0,
}
}
/// Whether a `script-src` source is narrow enough to be worth allowing. CSP
/// keywords (`'self'`, `'wasm-unsafe-eval'`) pass. A remote source must name a
/// non-wildcard host *and* a path reaching at least a publisher scope — a bare
/// origin, a scheme, or a registry root would put arbitrary third-party code
/// one injected `<script>` away.
fn is_pinned_script_source(source: &str) -> bool {
if source.starts_with('\'') {
return true;
}
!source.contains('*') && path_depth(source) >= 2
}
#[test]
fn script_src_trusts_no_bare_origins() {
for source in sources("script-src") {
assert!(
is_pinned_script_source(&source),
"script-src must stay scoped, found broad source `{source}`"
);
}
}
#[test]
fn pinned_script_source_rejects_broad_sources() {
// Guards the guard: the check above is only worth having if it fails on the
// shapes that reopen the allowlist.
for allowed in [
"'self'",
"'wasm-unsafe-eval'",
MEDIAPIPE_SCOPE,
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm/vision_wasm_internal.js",
] {
assert!(is_pinned_script_source(allowed), "{allowed} should pass");
}
for rejected in [
"https://cdn.jsdelivr.net",
"https://cdn.jsdelivr.net/",
"https://cdn.jsdelivr.net/npm/",
"https://cdn.jsdelivr.net/gh/",
"https://*.jsdelivr.net/npm/@mediapipe/",
"https:",
"*",
] {
assert!(
!is_pinned_script_source(rejected),
"{rejected} should be rejected"
);
}
}
#[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
// SHA-256 hashing (scripts only — Tauri nonces inline <style> elements via
// a different path), so neither escape hatch is ever needed here. The boot
// background style was moved to public/boot.css to avoid the nonce path for
// style-src; see boot.css for the full rationale.
assert!(!allowed.contains(&"'unsafe-inline'".to_owned()));
assert!(!allowed.contains(&"'unsafe-eval'".to_owned()));
}

View File

@@ -0,0 +1,181 @@
// 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.
//
// Lives on the frontend side because it checks the policy against frontend
// code (`MEDIAPIPE_WASM_BASE`), and the `desktop/**` CI filter covers both
// that code and `src-tauri/tauri.conf.json`.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { MEDIAPIPE_WASM_BASE } from "../features/profile/lib/animatedAvatarCapture.ts";
const TAURI_CONF = JSON.parse(
readFileSync(
new URL("../../src-tauri/tauri.conf.json", import.meta.url),
"utf8",
),
);
function cspDirectives() {
const csp = TAURI_CONF.app?.security?.csp;
assert.equal(
typeof csp,
"string",
"app.security.csp is set as a policy string",
);
const directives = new Map();
for (const directive of csp.split(";")) {
const [name, ...sources] = directive.split(/\s+/).filter(Boolean);
if (name) directives.set(name, sources);
}
return directives;
}
function sources(directive) {
const allowed = cspDirectives().get(directive);
assert.ok(allowed, `csp is missing the ${directive} directive`);
return allowed;
}
/** 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 = "https://cdn.jsdelivr.net/npm/@mediapipe/";
/** How many path segments a source narrows to past its origin. */
function pathDepth(source) {
const authority = source.split("://")[1];
if (authority === undefined) return 0;
const slash = authority.indexOf("/");
if (slash === -1) return 0;
return authority
.slice(slash + 1)
.split("/")
.filter(Boolean).length;
}
/** Whether a `script-src` source is narrow enough to be worth allowing. CSP
* keywords (`'self'`, `'wasm-unsafe-eval'`) pass. A remote source must name a
* non-wildcard host *and* a path reaching at least a publisher scope — a bare
* origin, a scheme, or a registry root would put arbitrary third-party code
* one injected `<script>` away. */
function isPinnedScriptSource(source) {
if (source.startsWith("'")) return true;
return !source.includes("*") && pathDepth(source) >= 2;
}
test("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.ok(sources("script-src").includes("'wasm-unsafe-eval'"));
});
test("script-src scopes the MediaPipe loader", () => {
// `FilesetResolver.forVisionTasks` loads `vision_wasm[_nosimd]_internal.js`
// via a `<script>` tag (which of the two depends on a runtime SIMD probe),
// so the loader URL the frontend builds has to fall inside the allowlisted
// prefix — checked here rather than discovered in a signed build.
assert.ok(
sources("script-src").includes(MEDIAPIPE_SCOPE),
`script-src must allow ${MEDIAPIPE_SCOPE}`,
);
assert.ok(
MEDIAPIPE_WASM_BASE.startsWith(MEDIAPIPE_SCOPE),
`MEDIAPIPE_WASM_BASE (${MEDIAPIPE_WASM_BASE}) must sit under the allowlisted ${MEDIAPIPE_SCOPE}`,
);
});
test("script-src trusts no bare origins", () => {
for (const source of sources("script-src")) {
assert.ok(
isPinnedScriptSource(source),
`script-src must stay scoped, found broad source \`${source}\``,
);
}
});
test("pinned script source check rejects broad sources", () => {
// Guards the guard: the check above is only worth having if it fails on the
// shapes that reopen the allowlist.
for (const allowed of [
"'self'",
"'wasm-unsafe-eval'",
MEDIAPIPE_SCOPE,
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm/vision_wasm_internal.js",
]) {
assert.ok(isPinnedScriptSource(allowed), `${allowed} should pass`);
}
for (const rejected of [
"https://cdn.jsdelivr.net",
"https://cdn.jsdelivr.net/",
"https://cdn.jsdelivr.net/npm/",
"https://cdn.jsdelivr.net/gh/",
"https://*.jsdelivr.net/npm/@mediapipe/",
"https:",
"*",
]) {
assert.ok(
!isPinnedScriptSource(rejected),
`${rejected} should be rejected`,
);
}
});
test("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 (const directive of ["img-src", "media-src", "connect-src"]) {
const allowed = sources(directive);
assert.ok(
allowed.includes("buzz-media:"),
`${directive} must allow buzz-media:`,
);
assert.ok(
allowed.includes("http://buzz-media.localhost"),
`${directive} must allow http://buzz-media.localhost`,
);
}
});
test("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.
const allowed = sources("connect-src");
for (const source of [
"ipc:",
"http://ipc.localhost",
"https:",
"http:",
"wss:",
"ws:",
]) {
assert.ok(allowed.includes(source), `connect-src must allow ${source}`);
}
});
test("script-src stays free of unsafe-inline and unsafe-eval", () => {
const allowed = sources("script-src");
// The inline boot script in index.html is covered by Tauri's build-time
// SHA-256 hashing (scripts only — Tauri nonces inline <style> elements via
// a different path), so neither escape hatch is ever needed here. The boot
// background style was moved to public/boot.css to avoid the nonce path for
// style-src; see boot.css for the full rationale.
assert.ok(!allowed.includes("'unsafe-inline'"));
assert.ok(!allowed.includes("'unsafe-eval'"));
});

View File

@@ -109,8 +109,9 @@ const PERSON_OUTLINE_OFFSETS = [
// always matches the JS API. `script-src` in tauri.conf.json allowlists the
// jsDelivr path prefix for the @mediapipe npm scope rather than the whole
// origin, which also serves arbitrary npm and GitHub scripts; this URL must
// stay under that prefix — `src-tauri/tests/csp.rs` checks that it does.
const MEDIAPIPE_WASM_BASE =
// stay under that prefix — `src/app/contentSecurityPolicy.test.mjs` checks
// that it does.
export const MEDIAPIPE_WASM_BASE =
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
const SELFIE_SEGMENTER_MODEL_URL =
"https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite";