diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index d6b67e1b4..007cbf4bc 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -273,6 +273,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[ ("automate.newScript", "Automate", "Scripting Tools", "New Script"), ("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"), ("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"), + ("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"), // Table Design ("table.rename", "Table Design", "Properties", "Table Name"), ("table.resize", "Table Design", "Properties", "Resize Table"), diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index b2d8c352d..f248c59f2 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1017,7 +1017,10 @@ pub(crate) fn has_scripts(wb: &Workbook) -> bool { /// Why a script step is refused, whatever the workbook's trust: scripts stay inside the /// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data, /// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run -/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) +/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by +/// command id and parameter name, so it is only the up-front refusal: the steps also run inside +/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and +/// [`run_script`] drops any `OpenUrl` request a step raises. fn denied_script_step(id: &str, params: &Json) -> Option { if id.starts_with("file.") || id.starts_with("link.") { return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); @@ -1029,7 +1032,12 @@ fn denied_script_step(id: &str, params: &Json) -> Option { } fn trust_scripts(s: &mut Session, p: &Json) -> Result { - let trusted = bool_param(p, "trusted").unwrap_or(true); + // Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent. + let trusted = match p.get("trusted") { + None => true, + Some(Json::Bool(b)) => *b, + Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")), + }; s.doc_mut()?.scripts_trusted = trusted; Ok(json!({"trusted": trusted})) } @@ -1045,7 +1053,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result { if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) { return Err(bad( "automate.runScript", - "scripts in this workbook came from a file and are disabled; trust the workbook first (Automate › Macros › Trust Scripts, or `trusted: true`)", + "scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)", )); } let steps: Vec = steps.into_iter().take(100_000).collect(); @@ -1055,13 +1063,25 @@ fn run_script(s: &mut Session, p: &Json) -> Result { return Err(bad("automate.runScript", why)); } } + let _sandbox = crate::io::ScriptSandbox::enter(); let mut n = 0; for st in &steps { let id = st["command"].as_str().unwrap_or(""); if id.starts_with("automate.") { continue; } - s.execute(id, st["params"].clone())?; + let before = s.ui_requests.len(); + let ran = s.execute(id, st["params"].clone()); + // A step that asks the host to open a URL leaves the app: drop the request and stop. + if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) { + let mut i = 0; + s.ui_requests.retain(|r| { + i += 1; + i <= before || !matches!(r, crate::UiRequest::OpenUrl(_)) + }); + return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)"))); + } + ran?; n += 1; } Ok(json!({"ran": n})) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5c0bf800b..b18e8577b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -556,8 +556,41 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { Ok(out) } +thread_local! { + /// How many [`ScriptSandbox`]es are alive on this thread. + static SCRIPT_SANDBOX: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a +/// stored script's steps run inside one (#2178), so no command reaches the file system from a +/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal. +pub(crate) struct ScriptSandbox(()); + +impl ScriptSandbox { + pub(crate) fn enter() -> Self { + let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1))); + ScriptSandbox(()) + } +} + +impl Drop for ScriptSandbox { + fn drop(&mut self) { + let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_sub(1))); + } +} + +/// Refuses file access while a [`ScriptSandbox`] is alive (and, failing closed, while the +/// thread is being torn down). +fn script_sandbox_check(path: &str) -> Result<()> { + if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) { + return Err(EngineError::Other(format!("{path}: scripts can't read or write files"))); + } + Ok(()) +} + #[cfg(not(target_arch = "wasm32"))] pub fn read_file(path: &str) -> Result> { + script_sandbox_check(path)?; let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; if meta.len() > 2 * 1024 * 1024 * 1024 { return Err(EngineError::Other("files larger than 2 GB aren't supported".into())); @@ -567,11 +600,13 @@ pub fn read_file(path: &str) -> Result> { #[cfg(target_arch = "wasm32")] pub fn read_file(path: &str) -> Result> { + script_sandbox_check(path)?; Err(EngineError::Other(format!("{path}: no file system in the browser"))) } #[cfg(not(target_arch = "wasm32"))] pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + script_sandbox_check(path)?; // Write to a temporary file, then rename: a crash never leaves a half-written workbook. let tmp = format!("{path}.sctmp"); std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| { @@ -584,6 +619,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { #[cfg(target_arch = "wasm32")] pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { + script_sandbox_check(path)?; Err(EngineError::Other(format!("{path}: no file system in the browser"))) } @@ -902,6 +938,32 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// While a script runs (#2178), file reads and writes are refused whatever command or + /// parameter asked for them; nesting and dropping the sandbox restore access. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn script_sandbox_refuses_file_access_until_dropped() { + let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let existing = dir.join("in.csv"); + std::fs::write(&existing, b"a,b\n").unwrap(); + let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string()); + { + let _outer = ScriptSandbox::enter(); + { + let _inner = ScriptSandbox::enter(); + assert!(read_file(&existing).is_err()); + } + assert!(read_file(&existing).is_err(), "the outer sandbox still holds"); + assert!(write_file(&out, b"leak").is_err()); + assert!(!std::path::Path::new(&out).exists()); + } + assert_eq!(read_file(&existing).unwrap(), b"a,b\n"); + write_file(&out, b"ok").unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + /// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a /// root entry and the given stream names. fn cfb(streams: &[&str]) -> Vec { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 567148d41..dcab1d204 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1147,6 +1147,44 @@ fn untrusted_workbook_scripts_are_refused_until_trusted() { assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); } +#[test] +fn trust_scripts_only_defaults_to_trusted_when_the_flag_is_missing() { + let mut s = opened_with_script(); + // A `trusted` that isn't a boolean is an error and leaves the workbook untrusted. + for wrong in [json!("false"), json!("true"), json!(0), json!(1), json!(null), json!([true])] { + let err = s.execute("automate.trustScripts", json!({"trusted": wrong})).unwrap_err().to_string(); + assert!(err.contains("trusted"), "{wrong}: {err}"); + assert!(!s.doc().unwrap().scripts_trusted, "{wrong} must not trust the workbook"); + assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); + } + // The refusal names where the Trust Scripts button actually is. + let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string(); + assert!(err.contains("View › Macros") && err.contains("automate.trustScripts"), "{err}"); + assert_eq!(s.execute("automate.trustScripts", json!({"trusted": false})).unwrap()["trusted"], false); + assert!(!s.doc().unwrap().scripts_trusted); + assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true); + assert!(s.doc().unwrap().scripts_trusted); +} + +#[test] +fn scripts_leave_file_access_working_after_they_run() { + // The sandbox a script runs in is lifted afterwards, also when a step fails. + let dir = std::env::temp_dir().join(format!("gridcraft-script-sandbox-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("in.csv"); + std::fs::write(&csv, b"a,b\n1,2\n").unwrap(); + let csv = csv.to_string_lossy().to_string(); + let mut s = s(); + let bad_step = json!({"command": "cell.set", "params": {"cell": "not a cell", "input": "x"}}); + s.execute("automate.newScript", json!({"name": "Bad", "commands": [bad_step]})).unwrap(); + assert!(s.execute("automate.runScript", json!({"name": "Bad"})).is_err()); + s.execute("automate.newScript", json!({"name": "Ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "1"}}]})).unwrap(); + s.execute("automate.runScript", json!({"name": "Ok"})).unwrap(); + s.execute("data.fromTextCsv", json!({"path": csv})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn scripts_cannot_reach_outside_the_workbook_even_when_trusted() { let dir = std::env::temp_dir().join(format!("gridcraft-script-deny-{}", std::process::id())); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d218c8169..ee8bcfb75 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 274 / 290 commands implemented (94.5%).** +**Overall: 275 / 291 commands implemented (94.5%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -17,7 +17,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Data | 23 | 28 | 82% | | Review | 15 | 18 | 83% | | View | 20 | 20 | 100% | -| Automate | 3 | 3 | 100% | +| Automate | 4 | 4 | 100% | | Table Design | 13 | 13 | 100% | | Chart Design | 8 | 8 | 100% | | Format | 3 | 3 | 100% | @@ -324,13 +324,14 @@ Implemented: - `view.switchWindows` Switch Windows (Window) - `view.macros` Macros (Macros) -## Automate (3 / 3, 100%) +## Automate (4 / 4, 100%) Implemented: - `automate.newScript` New Script (Scripting Tools) - `automate.allScripts` All Scripts (Scripting Tools) - `automate.recordActions` Record Actions (Scripting Tools) +- `automate.trustScripts` Trust Scripts (Scripting Tools) ## Table Design (13 / 13, 100%)