fix(acl): refuse capabilities the census cannot see, and name the failed check

tauri-build loads capabilities/**/*.{json,toml,json5} plus inline
app.security.capabilities from any tauri config or TAURI_CONFIG, but the
census read only top-level capabilities/*.json, so a .toml, a
subdirectory or an inline capability could over-grant a window with a
green build. build.rs now fails on any of those, on a JSON5/TOML tauri
config it cannot read, and on a `webviews` or `remote` key in a
capability file. OS/editor junk (.DS_Store, swap files) that tauri never
loads is skipped in capabilities/ and permissions/. Each failure is
headed by the check that failed rather than always "capabilities do not
match generate_handler!".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 22:32:46 -07:00
co-authored by Claude Opus 5.5
parent 05d991181d
commit 008c8c0328
2 changed files with 268 additions and 35 deletions
+118 -32
View File
@@ -4,6 +4,11 @@
//! 2.11 skips the ACL for app commands entirely (`webview/mod.rs:1794`), so any local window //! 2.11 skips the ACL for app commands entirely (`webview/mod.rs:1794`), so any local window
//! could call any command. //! could call any command.
//! //!
//! Because the census can only vouch for what it reads, the build also stops on any capability
//! tauri would load that the census does not: anything in `capabilities/` other than a
//! top-level `*.json`, a `webviews`/`remote` key, `app.security.capabilities` in a tauri config
//! or `TAURI_CONFIG`, and any hand-written file under `permissions/`.
//!
//! The parser and the rules live in `src/command_census.rs`, which `cargo test` also compiles, //! The parser and the rules live in `src/command_census.rs`, which `cargo test` also compiles,
//! so they have unit tests. Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`. //! so they have unit tests. Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`.
@@ -12,10 +17,12 @@ mod command_census;
use std::path::Path; use std::path::Path;
fn fail(problems: &[String]) -> ! { /// Stops the build. `what` names the check that failed, so a malformed capability file, a
/// stray entry or a hand-written permission does not read as a grant/handler mismatch.
fn fail(what: &str, problems: &[String], hint: &str) -> ! {
eprintln!(); eprintln!();
eprintln!( eprintln!(
"capabilities do not match generate_handler! ({} problem{}):", "{what} ({} problem{}):",
problems.len(), problems.len(),
if problems.len() == 1 { "" } else { "s" } if problems.len() == 1 { "" } else { "s" }
); );
@@ -23,49 +30,52 @@ fn fail(problems: &[String]) -> ! {
eprintln!(" - {p}"); eprintln!(" - {p}");
} }
eprintln!(); eprintln!();
eprintln!( eprintln!("{hint}");
"Every app command needs exactly one bare `allow-<command-with-dashes>` grant: \
`viewer_*` commands in capabilities/file-viewer.json, everything else in \
capabilities/default.json. See src/command_census.rs."
);
eprintln!(); eprintln!();
std::process::exit(1); std::process::exit(1);
} }
const LAYOUT_HINT: &str = "Every capability is a top-level capabilities/*.json file with a \
`windows` list and no `webviews` or `remote`, and no capability is declared anywhere else \
(tauri.conf.json, TAURI_CONFIG, subdirectories, .toml/.json5). The census in \
src/command_census.rs can only vouch for what it reads.";
fn file_name(path: &Path) -> String {
path.file_name()
.expect("a directory entry has a file name")
.to_string_lossy()
.into_owned()
}
fn main() { fn main() {
// tauri-build already emits rerun-if-changed for `capabilities` and `permissions`. // tauri-build already emits rerun-if-changed for `capabilities`, `permissions` and the
// tauri config files, and rerun-if-env-changed for TAURI_CONFIG.
println!("cargo:rerun-if-changed=src/lib.rs"); println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=src/command_census.rs"); println!("cargo:rerun-if-changed=src/command_census.rs");
let lib_rs = std::fs::read_to_string("src/lib.rs") let lib_rs = std::fs::read_to_string("src/lib.rs")
.expect("build.rs runs with CWD = src-tauri, so src/lib.rs must be readable"); .expect("build.rs runs with CWD = src-tauri, so src/lib.rs must be readable");
let Some(commands) = command_census::registered_commands(&lib_rs) else { let Some(commands) = command_census::registered_commands(&lib_rs) else {
fail(&["src/lib.rs has no `generate_handler![ … ])` block to derive the AppManifest from" fail(
.to_string()]); "missing generate_handler! block",
&["src/lib.rs has no `generate_handler![ … ])` block to derive the AppManifest from"
.to_string()],
"build.rs derives the AppManifest from that block; see src/command_census.rs.",
);
}; };
let mut files = Vec::new(); check_tauri_config();
for entry in std::fs::read_dir("capabilities").expect("capabilities/ must exist") { let files = read_capabilities();
let path = entry.expect("readable entry in capabilities/").path();
if path.extension().is_some_and(|e| e == "json") {
let name = path
.file_name()
.expect("a file name")
.to_string_lossy()
.into_owned();
let json =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
match command_census::capability_file(&name, &json) {
Ok(file) => files.push(file),
Err(problem) => fail(&[problem]),
}
}
}
files.sort_by(|a, b| a.name.cmp(&b.name));
let problems = command_census::check(&commands, &files); let problems = command_census::check(&commands, &files);
if !problems.is_empty() { if !problems.is_empty() {
fail(&problems); fail(
"capabilities do not match generate_handler!",
&problems,
"Every app command needs exactly one bare `allow-<command-with-dashes>` grant: \
`viewer_*` commands in capabilities/file-viewer.json, everything else in \
capabilities/default.json. See src/command_census.rs.",
);
} }
prune_permissions(&commands); prune_permissions(&commands);
@@ -92,7 +102,8 @@ fn main() {
/// tauri-build also emits `rerun-if-changed=permissions`, so regenerating everything would /// tauri-build also emits `rerun-if-changed=permissions`, so regenerating everything would
/// touch every mtime and re-run this script — and recompile the crate — on every cargo /// touch every mtime and re-run this script — and recompile the crate — on every cargo
/// invocation. Anything else under `permissions/` is a hand-written grant the census cannot /// invocation. Anything else under `permissions/` is a hand-written grant the census cannot
/// see, so it is refused. /// see, so it is refused — except OS/editor junk (`.DS_Store`, swap files), which tauri never
/// loads and which is skipped (see `command_census::is_os_junk`).
fn prune_permissions(commands: &[String]) { fn prune_permissions(commands: &[String]) {
let root = Path::new("permissions"); let root = Path::new("permissions");
let Ok(entries) = std::fs::read_dir(root) else { let Ok(entries) = std::fs::read_dir(root) else {
@@ -100,6 +111,10 @@ fn prune_permissions(commands: &[String]) {
}; };
for entry in entries { for entry in entries {
let path = entry.expect("readable entry in permissions/").path(); let path = entry.expect("readable entry in permissions/").path();
if path.is_file() && command_census::is_os_junk(&file_name(&path)) {
// .DS_Store and friends: tauri never loads them, so they cannot grant anything.
continue;
}
if path.file_name().is_some_and(|n| n == "autogenerated") && path.is_dir() { if path.file_name().is_some_and(|n| n == "autogenerated") && path.is_dir() {
for file in std::fs::read_dir(&path).expect("readable permissions/autogenerated") { for file in std::fs::read_dir(&path).expect("readable permissions/autogenerated") {
let file = file.expect("readable entry").path(); let file = file.expect("readable entry").path();
@@ -112,11 +127,82 @@ fn prune_permissions(commands: &[String]) {
} }
} }
} else { } else {
fail(&[format!( fail(
"hand-written permission",
&[format!(
"{} is not generated by build.rs; hand-written permissions are not allowed \ "{} is not generated by build.rs; hand-written permissions are not allowed \
(every grant is a bare allow-* string in a capability file)", (every grant is a bare allow-* string in a capability file)",
path.display() path.display()
)]); )],
"permissions/ holds only build.rs's autogenerated/ directory. Delete the entry; \
an app command is granted by listing allow-<command> in a capability file.",
);
} }
} }
} }
/// Every capability tauri will load, read the way the census reads it — or the build stops.
/// tauri-build loads `capabilities/**/*.{json,toml,json5}`; the census reads only top-level
/// `*.json`, so anything else tauri could load is refused rather than granted unchecked.
fn read_capabilities() -> Vec<command_census::CapabilityFile> {
let mut files = Vec::new();
let mut stray = Vec::new();
let mut invalid = Vec::new();
for entry in std::fs::read_dir("capabilities").expect("capabilities/ must exist") {
let path = entry.expect("readable entry in capabilities/").path();
let name = file_name(&path);
let is_file = path.is_file();
if is_file && command_census::is_os_junk(&name) {
continue;
}
if let Some(problem) = command_census::stray_capability_entry(&name, is_file) {
stray.push(problem);
continue;
}
let json = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
match command_census::capability_file(&name, &json) {
Ok(file) => files.push(file),
Err(problem) => invalid.push(problem),
}
}
stray.sort();
if !stray.is_empty() {
fail("stray entry in capabilities/", &stray, LAYOUT_HINT);
}
invalid.sort();
if !invalid.is_empty() {
fail("invalid capability file", &invalid, LAYOUT_HINT);
}
files.sort_by(|a, b| a.name.cmp(&b.name));
files
}
/// tauri also takes capabilities inline from `app.security.capabilities` in any of its config
/// files, or from the `TAURI_CONFIG` JSON that tauri-build merges over them. The census cannot
/// see those, so they are refused; so is a config in a format it cannot read (JSON5, TOML).
fn check_tauri_config() {
let mut problems = Vec::new();
for entry in std::fs::read_dir(".").expect("readable src-tauri/") {
let path = entry.expect("readable entry in src-tauri/").path();
let name = file_name(&path);
match command_census::tauri_config_file(&name) {
None => {}
Some(false) => problems.push(format!(
"{name}: the census reads JSON tauri configs only; a JSON5/TOML config could \
declare capabilities it cannot see"
)),
Some(true) => {
let json =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
problems.extend(command_census::tauri_config_problem(&name, &json));
}
}
}
if let Ok(json) = std::env::var("TAURI_CONFIG") {
problems.extend(command_census::tauri_config_problem("TAURI_CONFIG", &json));
}
problems.sort();
if !problems.is_empty() {
fail("capabilities declared outside capabilities/", &problems, LAYOUT_HINT);
}
}
+147
View File
@@ -75,6 +75,16 @@ pub struct CapabilityFile {
pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String> { pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String> {
let value: serde_json::Value = let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("{name}: not valid JSON: {e}"))?; serde_json::from_str(json).map_err(|e| format!("{name}: not valid JSON: {e}"))?;
// `webviews` would extend the grants to webviews by label (the browser-view pop-out is
// meant to be in no capability), and `remote` would extend them to a remote origin. The
// census reasons about `windows` only, so either key is refused rather than half-checked.
for key in ["webviews", "remote"] {
if value.get(key).is_some() {
return Err(format!(
"{name}: `{key}` is not allowed; capabilities here are scoped by `windows` only"
));
}
}
let windows = value["windows"] let windows = value["windows"]
.as_array() .as_array()
.ok_or_else(|| format!("{name}: `windows` must be an array"))? .ok_or_else(|| format!("{name}: `windows` must be an array"))?
@@ -105,6 +115,79 @@ pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String>
Ok(CapabilityFile { name: name.to_string(), windows, bare }) Ok(CapabilityFile { name: name.to_string(), windows, bare })
} }
/// Why an entry directly under `capabilities/` cannot be a capability the census reads, or
/// `None` if it is one (a top-level `*.json` file). tauri-build loads `capabilities/**/*` with
/// the extensions `json`, `toml` and (with a feature) `json5`, subdirectories included; the
/// census reads only top-level JSON, so anything else tauri might load is refused rather than
/// left for tauri to grant from unchecked. OS and editor junk, which tauri never loads, is the
/// caller's to skip first (see [`is_os_junk`]).
pub fn stray_capability_entry(name: &str, is_file: bool) -> Option<String> {
if !is_file {
return Some(format!(
"capabilities/{name} is not a regular file; tauri loads capabilities from \
subdirectories too, so every capability must be a top-level capabilities/*.json"
));
}
if name.ends_with(".json") {
return None;
}
Some(format!(
"capabilities/{name} is not a .json file; tauri may load it (it reads .toml and .json5 \
too) but the census cannot check it, so every capability must be a top-level \
capabilities/*.json"
))
}
/// Files the OS or an editor drops next to real ones (`.DS_Store`, `Thumbs.db`, `desktop.ini`,
/// Vim swap files, `name~` backups). tauri-build loads only `json`/`toml`/`json5` from
/// `capabilities/` and `permissions/`, so a junk name with one of those extensions (an Emacs
/// `.#default.json` lock, a macOS `._default.json`) is *not* junk: tauri would try to load it,
/// and the caller must refuse it.
pub fn is_os_junk(name: &str) -> bool {
let loadable = [".json", ".json5", ".toml"].iter().any(|e| name.ends_with(e));
!loadable
&& (matches!(name, ".DS_Store" | "Thumbs.db" | "desktop.ini")
|| name.ends_with(".swp")
|| name.ends_with(".swo")
|| name.ends_with('~'))
}
/// Which files next to `Cargo.toml` tauri reads as its config: `tauri.conf.json[5]`,
/// `Tauri.toml` and the per-platform `tauri.<platform>.conf.json[5]` / `Tauri.<platform>.toml`
/// (tauri-utils `config/parse.rs`). `Some(true)` = JSON the census can read, `Some(false)` = a
/// format it cannot (JSON5/TOML), `None` = not a tauri config file.
pub fn tauri_config_file(name: &str) -> Option<bool> {
if name.starts_with("tauri.") && name.ends_with(".conf.json") {
Some(true)
} else if (name.starts_with("tauri.") && name.ends_with(".conf.json5"))
|| (name.starts_with("Tauri.") && name.ends_with(".toml"))
{
Some(false)
} else {
None
}
}
/// A problem with a tauri config (a `tauri*.conf.json` file, or the `TAURI_CONFIG` JSON that
/// tauri-build merges over it), or `None`. `app.security.capabilities` is refused whenever it
/// is non-empty: an inline object is a capability the census never sees, and a list of
/// identifiers switches every *other* capability file off, which the census also assumes is
/// not happening.
pub fn tauri_config_problem(name: &str, json: &str) -> Option<String> {
let value: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(e) => return Some(format!("{name}: not valid JSON: {e}")),
};
match value.pointer("/app/security/capabilities") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::Array(a)) if a.is_empty() => None,
Some(_) => Some(format!(
"{name}: app.security.capabilities is not allowed; every capability lives in a \
top-level capabilities/*.json file, where the census checks it"
)),
}
}
/// Everything that must hold between the handler list and the capability files. Returns every /// Everything that must hold between the handler list and the capability files. Returns every
/// violation rather than the first, so a batch of forgotten grants is one build failure; an /// violation rather than the first, so a batch of forgotten grants is one build failure; an
/// empty vector is a pass. /// empty vector is a pass.
@@ -324,6 +407,70 @@ mod tests {
assert!(capability_file("x.json", "not json").unwrap_err().contains("x.json")); assert!(capability_file("x.json", "not json").unwrap_err().contains("x.json"));
} }
#[test]
fn webviews_and_remote_keys_are_refused() {
let with = |extra: &str| {
format!(r#"{{"windows": ["main"], {extra}, "permissions": ["allow-check-docker"]}}"#)
};
let err = capability_file("d.json", &with(r#""webviews": ["browser-view-*"]"#)).unwrap_err();
assert!(err.contains("d.json") && err.contains("`webviews`"), "{err}");
let err = capability_file("d.json", &with(r#""remote": {"urls": ["https://*"]}"#)).unwrap_err();
assert!(err.contains("`remote`"), "{err}");
// Present-but-empty is still refused: the key itself is the widening surface.
assert!(capability_file("d.json", &with(r#""webviews": []"#)).is_err());
}
#[test]
fn only_top_level_json_files_are_capabilities() {
assert_eq!(stray_capability_entry("default.json", true), None);
for name in ["extra.toml", "extra.json5", "notes.txt", ".DS_Store"] {
let err = stray_capability_entry(name, true).expect(name);
assert!(err.contains(name) && err.contains("not a .json file"), "{err}");
}
let err = stray_capability_entry("sub", false).unwrap();
assert!(err.contains("capabilities/sub") && err.contains("not a regular file"), "{err}");
// A directory named like a capability is still a directory.
assert!(stray_capability_entry("x.json", false).is_some());
}
#[test]
fn os_junk_is_recognised_but_never_something_tauri_would_load() {
for junk in [".DS_Store", "Thumbs.db", "desktop.ini", ".default.json.swp", ".x.swo", "default.json~"] {
assert!(is_os_junk(junk), "{junk}");
}
for real in ["default.json", "x.toml", "x.json5", ".#default.json", "._default.json", "notes.txt", "extra"] {
assert!(!is_os_junk(real), "{real}");
}
}
#[test]
fn tauri_config_files_are_found_by_name_and_format() {
assert_eq!(tauri_config_file("tauri.conf.json"), Some(true));
assert_eq!(tauri_config_file("tauri.linux.conf.json"), Some(true));
assert_eq!(tauri_config_file("tauri.conf.json5"), Some(false));
assert_eq!(tauri_config_file("tauri.windows.conf.json5"), Some(false));
assert_eq!(tauri_config_file("Tauri.toml"), Some(false));
assert_eq!(tauri_config_file("Tauri.macos.toml"), Some(false));
assert_eq!(tauri_config_file("Cargo.toml"), None);
assert_eq!(tauri_config_file("build.rs"), None);
}
#[test]
fn inline_capabilities_in_the_tauri_config_are_refused() {
let ok = r#"{"app": {"security": {"csp": "default-src 'self'"}}}"#;
assert_eq!(tauri_config_problem("tauri.conf.json", ok), None);
assert_eq!(tauri_config_problem("t", r#"{"app": {"security": {"capabilities": []}}}"#), None);
assert_eq!(tauri_config_problem("t", r#"{"build": {"beforeBuildCommand": ""}}"#), None);
let inline = r#"{"app": {"security": {"capabilities": [
{"identifier": "x", "windows": ["file-viewer-*"], "permissions": ["allow-read-container-file"]}
]}}}"#;
let err = tauri_config_problem("tauri.conf.json", inline).unwrap();
assert!(err.contains("tauri.conf.json") && err.contains("app.security.capabilities"), "{err}");
let by_name = r#"{"app": {"security": {"capabilities": ["default"]}}}"#;
assert!(tauri_config_problem("TAURI_CONFIG", by_name).unwrap().contains("TAURI_CONFIG"));
assert!(tauri_config_problem("t", "{").unwrap().contains("not valid JSON"));
}
#[test] #[test]
fn a_correct_census_has_no_problems() { fn a_correct_census_has_no_problems() {
assert_eq!(check(&cmds(THREE), &good_files()), Vec::<String>::new()); assert_eq!(check(&cmds(THREE), &good_files()), Vec::<String>::new());