From bcc611b3a9c8c4127ccb0cc7cb3c3607eef00c9b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:58:27 -0500 Subject: [PATCH] fix(engine): hold a plugin's text.preview to the permissions of the command it previews (#1959) * fix(engine): hold a plugin's text.preview to the permissions of the command it previews * address review: check PREVIEWABLE before recursing into a preview's inner command --- crates/engine/src/extensions/plugins.rs | 10 ++++++++++ crates/engine/src/plugins_tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/engine/src/extensions/plugins.rs b/crates/engine/src/extensions/plugins.rs index 2b1094306..208efedd2 100644 --- a/crates/engine/src/extensions/plugins.rs +++ b/crates/engine/src/extensions/plugins.rs @@ -149,6 +149,16 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { if DENIED.iter().any(|p| command.starts_with(p)) && !ALLOWED.contains(&command) { return Some(format!("`{command}` isn't available to plugins")); } + // A preview runs its inner command, which the plugin's permissions and the denied list must cover too. + // An inner command that can't be previewed (a nested preview) is refused by the preview itself, + // before it runs: descending into its parameters first would only rescan them. + if command == "text.preview" + && let Some(inner) = get(params, "command").and_then(Value::as_str) + && crate::textpreview::PREVIEWABLE.contains(&inner) + && let Some(why) = refusal(m, inner, get(params, "params").unwrap_or(&Value::Null)) + { + return Some(why); + } // A widget's own data is FigCraft's to write, so no plugin can forge or retarget a widget. if writes_widget_data(params, 0) { return Some(format!("plugins can't write `{}…` layer data", crate::widgets::DATA_PREFIX)); diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index dd60f8344..84f233fc9 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -153,6 +153,30 @@ fn read_only_plugins_and_allowlists_are_enforced() { assert_eq!(node(&s, a).name, "Rectangle"); } +#[test] +fn a_preview_is_held_to_the_permissions_of_the_command_it_shows() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0); + // Only text.preview is allowed: the node.set it would run is not. + install( + &mut s, + manifest("org.test.previewer", "write", &["text.preview"]), + &script(&[("text.preview", json!({"command": "node.set", "params": {"id": a, "props": {"name": "x"}}}))], OnRefusal::Fail, ""), + ); + let e = run_err(&mut s, "org.test.previewer"); + assert!(e.contains("`node.set` isn't in the commands"), "{e}"); + assert_eq!(node(&s, a).name, "Rectangle"); +} + +#[test] +fn a_nested_preview_is_refused_by_the_preview_itself() { + let mut s = Session::new(); + let nested = json!({"command": "text.preview", "params": {"command": "node.set", "params": {"props": {"name": "x"}}}}); + install(&mut s, manifest("org.test.nested", "write", &["text.preview"]), &script(&[("text.preview", nested)], OnRefusal::Fail, "")); + let e = run_err(&mut s, "org.test.nested"); + assert!(e.contains("can't be previewed"), "{e}"); +} + #[test] fn files_dialogs_history_and_plugins_are_off_limits() { let mut s = Session::new();