diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 56db72a22..d110149da 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -213,8 +213,10 @@ fn resolve(path: &str) -> Result { return Err("automation path rejected: path is empty".into()); } // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. - if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { - return Err(format!("automation path rejected: reserved device names are not allowed: {path}")); + if cfg!(windows) + && let Some(why) = windows_name_refusal(path) + { + return Err(format!("automation path rejected: {why}: {path}")); } let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; // The names below the nearest existing folder, last first. @@ -280,6 +282,18 @@ pub(crate) fn is_device_name(component: &str) -> bool { matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") } +/// Why Windows would not take `path` as an ordinary file name, for the first of its components +/// that is a device name, or holds a `:` (a drive letter's own colon belongs to the prefix, not to +/// a name, so only a stream such as `x.svg:evil` has one). (Judged by name alone, so it can be +/// tested on any platform; `resolve` applies it on Windows only.) +fn windows_name_refusal(path: &str) -> Option<&'static str> { + Path::new(path).components().find_map(|c| match c { + Component::Normal(n) if is_device_name(&n.to_string_lossy()) => Some("reserved device names are not allowed"), + Component::Normal(n) if n.to_string_lossy().contains(':') => Some("alternate data streams are not allowed"), + _ => None, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -497,4 +511,29 @@ mod tests { assert!(!is_device_name(f), "{f}"); } } + + /// #3604: on Windows `x.svg:evil` writes a hidden NTFS alternate data stream of `x.svg`, which + /// stays inside the root yet shows in no folder listing, so a `:` in any component after the + /// drive is refused (a drive letter's own colon is not a component). + #[test] + fn alternate_data_streams_are_refused() { + for p in ["x.svg:evil", "out/x.svg:evil", "out/x.svg:evil:$DATA", "out/sub:evil/x.svg", "out/x.svg::$DATA", "out/:evil"] { + assert_eq!(windows_name_refusal(p), Some("alternate data streams are not allowed"), "{p}"); + } + for p in ["x.svg", "out/x.svg", "out/a b.svg", "out/x.svg.evil", "out/NUL"] { + assert_ne!(windows_name_refusal(p), Some("alternate data streams are not allowed"), "{p}"); + } + assert_eq!(windows_name_refusal("out/NUL"), Some("reserved device names are not allowed")); + assert_eq!(windows_name_refusal("out/x.svg"), None); + } + + #[cfg(windows)] + #[test] + fn windows_refuses_a_stream_inside_a_write_root() { + let (base, roots) = folders("streams"); + let e = roots.check(&s(base.join("out").join("x.svg:evil")), Access::Write).unwrap_err(); + assert!(e.contains("alternate data streams"), "{e}"); + assert_eq!(roots.check(&s(base.join("out/x.svg")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2f2a86d26..1e718c9da 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,6 +1,6 @@ # Control protocol -> **Last updated:** 2026-10-25 · **Change:** Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). +> **Last updated:** 2026-10-31 · **Change:** Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). `vectorcraft --control ` listens on `127.0.0.1:` (loopback only). The port must be a number from 1 to 65535 (`--control=` works too), given once; otherwise the app prints an error and exits @@ -68,7 +68,7 @@ writes (save, export, package, captures, library saves…) inside a write root; ordinary error reply. Paths are made absolute against the app's working directory, links are followed (for a file that doesn't exist yet, its folder's), and the comparison is by whole folder names (`/work2` is not inside `/work`), ignoring case on Windows; `..` below a folder that doesn't exist, a link that leads -nowhere and Windows device names are refused. A root left out grants none of its access. The roots must be +nowhere, Windows device names and, on Windows, a `:` in any name after the drive (an NTFS alternate data stream such as `x.svg:evil`) are refused. A root left out grants none of its access. The roots must be existing folders, or the app doesn't start; without `--control` they are ignored. See `docs/mcp.md` › Confining file access for the details. @@ -955,6 +955,7 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Confining file access: on Windows a `:` in a name after the drive (an NTFS alternate data stream) is refused with the device names (#3604). | | 2026-10-25 | minor | Panel docking on craft-ui's layout: `ui.docking`, the floating and dock parameters, native floating windows. | | 2026-10-24 | minor | Documented the brush library panel commands (`window.brushLibrary`, `.other`, `ui.saveBrushLibrary`, `window.userBrushLibrary1`…`10`). | | 2026-10-24 | minor | Documented `ui.screenshot`'s 64 MiB inline-PNG reply cap: with `path` the file is still written and `pngBase64` is omitted when over the cap; a path-less `data: true` request fails before encoding. | diff --git a/docs/mcp.md b/docs/mcp.md index 81681d6c2..90ce4f6c3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -125,7 +125,8 @@ the suite) keep the agent to the folders you give it: doesn't exist is refused, as is a link that leads nowhere (writing through it would create its target). The result is compared with the root by whole folder names: `/work2/x` is not inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\…`) are resolved like drive-letter ones, and - device names (`CON`, `NUL`, `COM1`…) are refused. + device names (`CON`, `NUL`, `COM1`…) and a `:` in a name after the drive (an NTFS alternate data stream + such as `x.svg:evil`, which hides data in a stream no folder listing shows) are refused. - With `--connect`, the server makes the paths of `open_file`, `save_file`, `export` (`path` or `options.path`) and `screenshot {path}` absolute against its own working directory before it sends them to the app. Paths inside `run_command` and `command_batch` params go to the app as given, and the