mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-28 20:56:54 -06:00
scripts: trust gate review fixes (#2178)
- automate.trustScripts: only a missing `trusted` defaults to true; a non-boolean value is an error and leaves the workbook untrusted. - The refusal message points at Trust Scripts in View > Macros (Alt+F8) and at automate.trustScripts. - Catalog row for automate.trustScripts; parity checklist regenerated. - Script steps run inside an io::ScriptSandbox (thread-local) so read_file/write_file refuse whatever the parameter is called, and a step that raises OpenUrl is dropped and stops the script. The id/param deny list stays as the up-front refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6dedb9310b
commit
b5b3969377
@@ -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"),
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
}
|
||||
|
||||
fn trust_scripts(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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<Json> {
|
||||
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<Json> = steps.into_iter().take(100_000).collect();
|
||||
@@ -1055,13 +1063,25 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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}))
|
||||
|
||||
@@ -556,8 +556,41 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// How many [`ScriptSandbox`]es are alive on this thread.
|
||||
static SCRIPT_SANDBOX: std::cell::Cell<u32> = 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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
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<u8> {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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%)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user