The tray's right-click menu is drawn by the app: one tree from tray.rs (stable submenu keys), one frameless always-on-top menu-N window per panel rendered by tray-menu.html with the design system, placed inside the monitor's work area. A pure panel book keeps transient and pinned panels apart; pinned panels (pin button, Ctrl+click, middle click) are draggable, step aside from each other and are saved in state.json (menuPins) and restored at start. tray_update pushes the tree to the open panels, which update in place; closed panels are destroyed. The native menu, rendered from the same tree, stays as the fallback and on Linux. Only menu-* windows get the menu_* commands (menu.json). In-app context menus get the same behaviour through a pure menu stack (ui/menu-stack.ts): pin a menu, tear off a submenu, pinned panels survive the next menu, are draggable and re-read their entries from live sources so data changes update them in place. Also drops a stray diff3 marker line from CHANGELOG.md.
290 lines
15 KiB
Rust
290 lines
15 KiB
Rust
//! Security invariants of the desktop shell: capability allow-lists, CSP, the command list, the window / origin
|
|
//! checks every command runs, what the webview may send to the sidecar, and where links may go.
|
|
use std::collections::BTreeSet;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde_json::{json, Value};
|
|
use storebound_desktop::policy::*;
|
|
use storebound_desktop::{CARD_COMMANDS, COMMANDS, MENU_COMMANDS};
|
|
use url::Url;
|
|
|
|
fn root() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
}
|
|
|
|
fn read_json(p: &Path) -> Value {
|
|
serde_json::from_str(&fs::read_to_string(p).unwrap()).unwrap_or_else(|e| panic!("{}: {e}", p.display()))
|
|
}
|
|
|
|
fn u(s: &str) -> Url {
|
|
Url::parse(s).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn one_capability_for_the_main_window_with_exactly_the_app_commands() {
|
|
let dir = root().join("capabilities");
|
|
let mut files: Vec<PathBuf> = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
|
|
files.sort();
|
|
assert_eq!(files.len(), 3, "main, card and menu capability files expected, found {files:?}");
|
|
let cap = read_json(&dir.join("main.json"));
|
|
assert_eq!(cap["identifier"], "main-window");
|
|
assert_eq!(cap["windows"], json!(["main"]));
|
|
assert!(cap.get("webviews").is_none(), "no webview-scoped grants");
|
|
assert!(cap.get("remote").is_none(), "no remote origins");
|
|
assert_ne!(cap["local"], json!(false));
|
|
assert!(cap["description"].as_str().unwrap().len() > 80, "capabilities carry a real description");
|
|
|
|
let got: BTreeSet<String> = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().expect("plain permission ids only").to_string()).collect();
|
|
let mut want: BTreeSet<String> = COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect();
|
|
want.insert("core:event:allow-listen".into());
|
|
want.insert("core:event:allow-unlisten".into());
|
|
assert_eq!(got, want, "capability permissions must be exactly the app commands + event listen");
|
|
for p in &got {
|
|
for plugin in ["shell:", "fs:", "dialog:", "opener:", "updater:", "process:", "notification:", "deep-link:", "window-state:", "single-instance:", "http:", "core:webview", "core:window", "core:path", "core:default"] {
|
|
assert!(!p.starts_with(plugin), "{p}: no plugin or core API is granted to the webview");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_card_window_gets_only_the_card_commands() {
|
|
let cap = read_json(&root().join("capabilities/card.json"));
|
|
assert_eq!(cap["identifier"], "card-window");
|
|
assert_eq!(cap["windows"], json!(["card"]));
|
|
assert!(cap.get("webviews").is_none() && cap.get("remote").is_none());
|
|
assert_ne!(cap["local"], json!(false));
|
|
let got: BTreeSet<String> = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect();
|
|
let mut want: BTreeSet<String> = CARD_COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect();
|
|
want.insert("core:event:allow-listen".into());
|
|
want.insert("core:event:allow-unlisten".into());
|
|
assert_eq!(got, want);
|
|
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
|
|
for c in CARD_COMMANDS {
|
|
let at = cmds.find(&format!("pub fn {c}(")).unwrap_or_else(|| panic!("{c} not in commands.rs"));
|
|
let body = &cmds[at..];
|
|
let open = body.find('{').unwrap();
|
|
assert!(body[open + 1..].trim_start().starts_with("guard_card(&window)?;"), "{c} must start with guard_card(&window)?");
|
|
}
|
|
assert!(require_card("card", &u("tauri://localhost/card.html"), false).is_ok());
|
|
assert!(require_card("main", &u("tauri://localhost/"), false).is_err());
|
|
assert!(require_main("card", &u("tauri://localhost/card.html"), false).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn the_menu_panels_get_only_the_menu_commands() {
|
|
let cap = read_json(&root().join("capabilities/menu.json"));
|
|
assert_eq!(cap["identifier"], "menu-window");
|
|
assert_eq!(cap["windows"], json!(["menu-*"]));
|
|
assert!(cap.get("webviews").is_none() && cap.get("remote").is_none());
|
|
assert_ne!(cap["local"], json!(false));
|
|
let got: BTreeSet<String> = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect();
|
|
let mut want: BTreeSet<String> = MENU_COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect();
|
|
want.insert("core:event:allow-listen".into());
|
|
want.insert("core:event:allow-unlisten".into());
|
|
// a pinned panel is dragged by its header (data-tauri-drag-region): only its own window
|
|
want.insert("core:window:allow-start-dragging".into());
|
|
assert_eq!(got, want);
|
|
// the menu commands never leak into the main or card capability
|
|
for other in ["main.json", "card.json"] {
|
|
let cap = read_json(&root().join("capabilities").join(other));
|
|
let perms = cap["permissions"].to_string();
|
|
for c in MENU_COMMANDS {
|
|
assert!(!perms.contains(&format!("\"allow-{}\"", c.replace('_', "-"))), "{c} granted in {other}");
|
|
}
|
|
}
|
|
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
|
|
for c in MENU_COMMANDS {
|
|
let at = cmds.find(&format!("pub fn {c}(")).unwrap_or_else(|| panic!("{c} not in commands.rs"));
|
|
let body = &cmds[at..];
|
|
let open = body.find('{').unwrap();
|
|
assert!(body[open + 1..].trim_start().starts_with("guard_menu(&window)?;"), "{c} must start with guard_menu(&window)?");
|
|
}
|
|
assert!(require_menu("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_ok());
|
|
assert!(require_menu("menu-12", &u("http://tauri.localhost/tray-menu.html"), false).is_ok());
|
|
for bad in ["main", "card", "menu-", "menu-x", "menu-1a", "menus-1"] {
|
|
assert!(require_menu(bad, &u("tauri://localhost/tray-menu.html"), false).is_err(), "{bad}");
|
|
}
|
|
assert!(require_menu("menu-1", &u("https://example.com/tray-menu.html"), false).is_err());
|
|
assert!(require_main("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err());
|
|
assert!(require_card("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn every_registered_command_is_in_the_list_and_nothing_else() {
|
|
let lib = fs::read_to_string(root().join("src/lib.rs")).unwrap();
|
|
let start = lib.find("generate_handler![").unwrap() + "generate_handler![".len();
|
|
let end = start + lib[start..].find(']').unwrap();
|
|
let registered: Vec<String> = lib[start..end]
|
|
.split(',')
|
|
.map(|s| s.trim().trim_start_matches("commands::").to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).chain(MENU_COMMANDS).map(|s| s.to_string()).collect::<Vec<_>>());
|
|
// every command checks the caller first
|
|
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
|
|
for c in COMMANDS {
|
|
let at = cmds.find(&format!("pub fn {c}(")).or_else(|| cmds.find(&format!("pub async fn {c}("))).unwrap_or_else(|| panic!("{c} not in commands.rs"));
|
|
let body = &cmds[at..];
|
|
let open = body.find('{').unwrap();
|
|
let first = body[open + 1..].trim_start();
|
|
assert!(first.starts_with("guard(&window)?;"), "{c} must start with guard(&window)?");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn csp_is_strict_and_the_config_has_no_backdoors() {
|
|
let conf = read_json(&root().join("tauri.conf.json"));
|
|
let csp = &conf["app"]["security"]["csp"];
|
|
assert_eq!(csp["default-src"], "'self'");
|
|
assert_eq!(csp["script-src"], "'self'");
|
|
assert_eq!(csp["style-src"], "'self'");
|
|
assert_eq!(csp["connect-src"], "ipc: http://ipc.localhost");
|
|
assert_eq!(csp["object-src"], "'none'");
|
|
assert_eq!(csp["frame-src"], "'none'");
|
|
assert_eq!(csp["base-uri"], "'none'");
|
|
for (k, v) in csp.as_object().unwrap() {
|
|
let v = v.as_str().unwrap();
|
|
assert!(!v.contains("unsafe-eval") && !v.contains("unsafe-inline") && !v.contains('*'), "{k}: {v}");
|
|
assert!(!v.contains("https:") && !v.contains("http://") || v == "ipc: http://ipc.localhost", "{k}: no remote sources: {v}");
|
|
}
|
|
assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window", "menu-window"]));
|
|
assert_eq!(conf["app"]["security"]["freezePrototype"], json!(true));
|
|
assert!(conf["app"].get("withGlobalTauri").is_none() || conf["app"]["withGlobalTauri"] == json!(false));
|
|
let windows = conf["app"]["windows"].as_array().unwrap();
|
|
assert_eq!(windows.len(), 1);
|
|
assert_eq!(windows[0]["label"], MAIN);
|
|
assert!(windows[0].get("url").is_none(), "the main window loads the bundled index only");
|
|
assert_eq!(conf["bundle"]["externalBin"], json!(["binaries/storebound"]));
|
|
assert_eq!(conf["plugins"]["deep-link"]["desktop"]["schemes"], json!(["storebound"]));
|
|
for e in conf["plugins"]["updater"]["endpoints"].as_array().unwrap() {
|
|
assert!(e.as_str().unwrap().starts_with("https://storebound.dev/"));
|
|
}
|
|
assert_eq!(conf["build"]["devUrl"], format!("http://{DEV_HOST}:{DEV_PORT}"));
|
|
}
|
|
|
|
#[test]
|
|
fn platform_configs_never_replace_the_window_list() {
|
|
// Tauri merges a platform config's `app.windows` by replacing the whole array (the main window would lose its
|
|
// label, size or CSP-relevant settings). Keep windows in tauri.conf.json only.
|
|
for e in fs::read_dir(root()).unwrap() {
|
|
let p = e.unwrap().path();
|
|
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
|
if name.starts_with("tauri.") && name.ends_with(".conf.json") && name != "tauri.conf.json" {
|
|
let c = read_json(&p);
|
|
assert!(c["app"].get("windows").is_none(), "{name} must not set app.windows");
|
|
assert!(c["app"]["security"].get("csp").is_none(), "{name} must not change the CSP");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn only_main_with_local_pages_may_call() {
|
|
for ok in ["tauri://localhost/", "tauri://localhost/index.html", "http://tauri.localhost/", "https://tauri.localhost/index.html"] {
|
|
assert!(require_main("main", &u(ok), false).is_ok(), "{ok}");
|
|
}
|
|
assert!(require_main("main", &u("http://127.0.0.1:1420/"), true).is_ok());
|
|
for bad in [
|
|
"http://127.0.0.1:1420/",
|
|
"http://localhost:1420/",
|
|
"https://storebound.dev/",
|
|
"tauri://evil/",
|
|
"tauri://localhost:8080/",
|
|
"http://tauri.localhost:8080/",
|
|
"http://tauri.localhost.evil.test/",
|
|
&format!("http://{}@tauri.localhost/", "user"),
|
|
"file:///tmp/index.html",
|
|
"data:text/html,hi",
|
|
] {
|
|
assert!(require_main("main", &u(bad), false).is_err(), "accepted {bad}");
|
|
}
|
|
assert!(require_main("main", &u("http://127.0.0.1:1421/"), true).is_err());
|
|
assert!(require_main("http://localhost", &u("tauri://localhost/"), false).is_err());
|
|
assert!(require_main("popup", &u("tauri://localhost/"), false).is_err());
|
|
assert!(require_main("", &u("tauri://localhost/"), false).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn webview_calls_to_the_sidecar_are_reads_or_dry_runs() {
|
|
// a write from the webview always becomes a dry run
|
|
let p = sanitize_js_call("play.listing", Some(Kind::Write), &json!({ "args": { "only": "en-US" } })).unwrap();
|
|
assert_eq!(p, json!({ "args": { "only": "en-US" }, "dryRun": true }));
|
|
// the host's keys are refused, so is anything unknown
|
|
for key in ["confirmToken", "credentials", "dryRun", "project", "projects", "secret", "env"] {
|
|
let mut o = serde_json::Map::new();
|
|
o.insert(key.into(), json!(false));
|
|
assert!(sanitize_js_call("play.listing", Some(Kind::Write), &Value::Object(o.clone())).is_err(), "{key}");
|
|
assert!(sanitize_js_call("check", Some(Kind::Read), &Value::Object(o)).is_err(), "{key}");
|
|
}
|
|
assert!(sanitize_js_call("session.hello", Some(Kind::Session), &json!({})).is_err());
|
|
assert!(sanitize_js_call("portfolio.status", Some(Kind::Portfolio), &json!({})).is_err());
|
|
assert!(sanitize_js_call("apple.submit", None, &json!({})).is_err());
|
|
assert!(sanitize_js_call("check", Some(Kind::Read), &json!([1])).is_err());
|
|
assert_eq!(sanitize_js_call("system.version", Some(Kind::System), &Value::Null).unwrap(), json!({}));
|
|
}
|
|
|
|
#[test]
|
|
fn sidecar_starts_with_fixed_arguments_from_one_place() {
|
|
assert_eq!(SIDECAR_ARGS, ["serve", "--stdio"]);
|
|
assert_eq!(SIDECAR, "storebound");
|
|
let mut sites = 0;
|
|
for e in fs::read_dir(root().join("src")).unwrap() {
|
|
let p = e.unwrap().path();
|
|
let src = fs::read_to_string(&p).unwrap();
|
|
let n = src.matches(".sidecar(").count();
|
|
if n > 0 {
|
|
assert!(p.ends_with("sidecar.rs"), "{} spawns the sidecar", p.display());
|
|
assert!(src.contains(".args(policy::SIDECAR_ARGS)"));
|
|
}
|
|
assert!(!src.contains(".command("), "{}: no other programs are started", p.display());
|
|
// the release self-test (--self-test) starts the bundled sidecar with the same fixed arguments, nothing else
|
|
if p.ends_with("selftest.rs") {
|
|
assert_eq!(src.matches("Command::new(").count(), 1);
|
|
assert!(src.contains("Command::new(&bin)\n .args(policy::SIDECAR_ARGS)"));
|
|
continue;
|
|
}
|
|
// Fix with AI: only the claude binary (claude_command checks its name), git, and kill / taskkill by PID
|
|
if p.ends_with("ai_fix.rs") {
|
|
for (i, _) in src.match_indices("Command::new(") {
|
|
let arg = &src[i + "Command::new(".len()..];
|
|
let ok = ["claude)", "\"git\")", "\"kill\")", "\"taskkill\")"];
|
|
assert!(ok.iter().any(|o| arg.starts_with(o)), "unexpected program: {}", &arg[..arg.find(')').unwrap_or(20).min(40)]);
|
|
}
|
|
assert!(src.contains("if !matches!(name, \"claude\" | \"claude.exe\" | \"claude.cmd\")"));
|
|
continue;
|
|
}
|
|
// other programs: only the download checks (unpack, signature, version banner), with fixed program names
|
|
if src.contains("Command::new(") {
|
|
assert!(p.ends_with("downloads.rs"), "{} starts a program", p.display());
|
|
for (i, _) in src.match_indices("Command::new(") {
|
|
let arg = &src[i + "Command::new(".len()..];
|
|
let ok = ["\"tar\")", "\"/usr/bin/ditto\")", "\"/usr/bin/codesign\")", "\"powershell\")", "exe)"];
|
|
assert!(ok.iter().any(|o| arg.starts_with(o)), "unexpected program: {}", &arg[..arg.find(')').unwrap_or(20).min(40)]);
|
|
}
|
|
}
|
|
sites += n;
|
|
}
|
|
assert_eq!(sites, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn links_open_only_for_the_consoles_and_the_site() {
|
|
for ok in ["https://play.google.com/console/", "https://appstoreconnect.apple.com/apps", "https://storebound.dev/docs"] {
|
|
assert!(external_allowed(&u(ok)), "{ok}");
|
|
}
|
|
for bad in [
|
|
"http://play.google.com/console",
|
|
"https://play.google.com.evil.test/",
|
|
&format!("https://{}@appstoreconnect.apple.com/", "user"),
|
|
"https://storebound.dev:8443/",
|
|
"file:///etc/passwd",
|
|
"javascript:alert(1)",
|
|
"https://evil.test/?https://storebound.dev",
|
|
] {
|
|
assert!(!external_allowed(&u(bad)), "{bad}");
|
|
}
|
|
assert_eq!(deep_link_screen(&u("storebound://apps")), Some("apps"));
|
|
assert_eq!(deep_link_screen(&u("storebound://portfolio?x=1")), Some("portfolio"));
|
|
assert_eq!(deep_link_screen(&u("storebound://write")), None);
|
|
assert_eq!(deep_link_screen(&u("https://projects")), None);
|
|
}
|