fix(engine): keep plugins out of branches, saved versions and library links

A read-only plugin could archive, merge, create or update a branch: branch
state lives beside the document, so neither the command's history flag nor
the document Arc check saw the write. Any plugin could read a saved version
through dev.compare's version parameter although version.* is denied, and
library.list handed plugins each library's path, which for a server library
is the share link with its token.
This commit is contained in:
Dylan M. Taylor committed 2026-11-03 15:46:13 +00:00
1 parent 02c38a6d95
commit 02db900cc8
2 files changed
+56 -4

No files matched your search

+17
View File
@@ -46,3 +46,20 @@ fn a_plugin_gets_names_but_no_paths() {
assert!(out.contains("Front"), "{out}");
assert!(!out.contains(SECRET) && !out.contains("path"), "{out}");
}
#[test]
fn a_plugin_gets_library_names_but_not_their_links() {
const LINK: &str = "ws://tracker.example:9/?file=f&token=per-file-secret";
let mut s = Session::new();
s.doc_mut().unwrap().libraries_mut().enabled.push(figcraft_doc::LibraryLink {
id: "lib".into(),
name: "Kit".into(),
path: LINK.into(),
version: 3,
});
let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))";
install(&mut s, manifest("org.test.libs", "read", &[]), &script(&[("library.list", json!({}))], OnRefusal::Fail, tail));
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.libs"}))["output"].to_string();
assert!(out.contains("Kit"), "{out}");
assert!(!out.contains("per-file-secret") && !out.contains("\\\"path\\\""), "{out}");
}
+39 -4
View File
@@ -170,12 +170,21 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
if command == "variable.import" && get(params, "variables").and_then(Value::as_str).is_some_and(|t| !t.trim_start().starts_with('{')) {
return Some("plugins can't read files: pass the variables JSON itself".into());
}
// A saved version is the file's history, which plugins can't read (`version.` is denied).
if command == "dev.compare" && get(params, "version").is_some_and(|v| !v.is_null()) {
return Some("plugins can't read saved versions: leave out `version`".into());
}
if !m.permissions.allows(command) {
return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name));
}
if m.permissions.access == Access::Read && find_command(command).is_some_and(|c| c.history) {
return Some(format!("`{command}` edits the document, and {} may only read it", m.name));
}
// Branches live beside the document (not in its undo history or its `Arc`), so the write
// checks below don't see them change.
if m.permissions.access == Access::Read && matches!(command, "branch.create" | "branch.updateFromMain" | "branch.merge" | "branch.archive") {
return Some(format!("`{command}` changes the file's branches, and {} may only read it", m.name));
}
None
}
@@ -253,10 +262,19 @@ impl figcraft_plugin::Host for EngineHost<'_> {
Ok(v)
});
r.map(|mut v| {
// A plugin gets the open files' names, not where they are on disk (#1117).
if command == "doc.list" {
for e in v.as_array_mut().into_iter().flatten().filter_map(Value::as_object_mut) {
e.remove("path");
// A plugin gets the open files' and libraries' names, not where they are on disk or
// the share links (with their tokens) that reach them (#1117).
if matches!(command, "doc.list" | "library.list") {
// `doc.list` is a list; `library.list` keeps its list under `libraries`.
let list = if command == "library.list" { v.get_mut("libraries") } else { Some(&mut v) };
for e in list.and_then(Value::as_array_mut).into_iter().flatten().filter_map(Value::as_object_mut) {
// An error may quote the path too.
if let Some(Value::String(path)) = e.remove("path")
&& !path.is_empty()
&& let Some(Value::String(err)) = e.get_mut("error")
{
*err = err.replace(&path, "the library");
}
}
}
v.to_string()
@@ -698,6 +716,23 @@ mod refusal_tests {
let r = refusal(&m, "library.publishToServer", &json!({}));
assert!(r.is_some(), "library.publishToServer uploads the document and must be refused");
}
#[test]
fn read_only_plugin_cannot_change_branches() {
let m = Manifest::parse(r#"{"id":"x.y","name":"X","main":"plugin.wasm"}"#).unwrap();
for cmd in ["branch.archive", "branch.updateFromMain", "branch.merge", "branch.create"] {
assert!(refusal(&m, cmd, &json!({})).is_some(), "{cmd} changes persisted branch state");
}
assert!(refusal(&m, "branch.list", &json!({})).is_none());
}
#[test]
fn plugins_cannot_compare_against_a_saved_version() {
let m = Manifest::parse(r#"{"id":"x.y","name":"X","main":"plugin.wasm","permissions":{"access":"write"}}"#).unwrap();
assert!(refusal(&m, "dev.compare", &json!({"version": "v1"})).is_some());
assert!(refusal(&m, "dev.compare", &json!({"version": null})).is_none());
assert!(refusal(&m, "dev.compare", &json!({})).is_none());
}
}
#[cfg(test)]