diff --git a/MODULE.md b/MODULE.md index 6e1064c4..32c86bcf 100644 --- a/MODULE.md +++ b/MODULE.md @@ -48,7 +48,9 @@ checks. Check is an internal trusted-host operation, not a model-visible tool. Path checks require the host's absolute operation-resolved target, resolve existing ancestors including symlinks, and enforce the credential/system/traversal floor on lexical and resolved forms even when -policy is disabled. Unknown native path dialects, broken symlinks and failed +policy is disabled. Traversal is any segment that starts with `..` and holds only dots and +spaces (`..`, `.. `, `...`), on every OS, since Windows normalizes those to `..`; such a +`forbidden_paths` entry is rejected at registration. Unknown native path dialects, broken symlinks and failed resolution deny. Enabled action roots must resolve to existing directories; workspace and configured forbidden roots deny before approval. New files can be checked through their existing parent. Permission does not guarantee @@ -503,7 +505,8 @@ cannot bind the call answers an error, never an allow. Latency budgets are per c like `CheckPath`. - `ProtectedPath2` (`ProtectedPathRequest2` -> `ProtectedPathResponse2`): the unconditional lexical floor with no scope and no filesystem access: credential stores, system and - installation roots, `..` traversal, NUL bytes and Windows roots and extended prefixes, on every + installation roots, parent-directory traversal (`..` and any segment of two or more dots + followed only by dots and spaces, such as `.. ` or `...`, which Windows normalizes to `..`), NUL bytes and Windows roots and extended prefixes, on every OS. At most 4096 paths. Budget: in memory, sub-millisecond. - `ProtectedFloor2` (no arguments): the floor's vocabulary (credential directories, single-file stores, segment pairs, lower-cased absolute prefixes), built from the constants the matcher diff --git a/crates/tinysecurity-bus/src/path_batch.rs b/crates/tinysecurity-bus/src/path_batch.rs index 92991b32..a7786ccd 100644 --- a/crates/tinysecurity-bus/src/path_batch.rs +++ b/crates/tinysecurity-bus/src/path_batch.rs @@ -61,7 +61,8 @@ pub struct PathBatchResponse2 { /// Query the unconditional protected-path floor for paths outside any scope. /// /// The floor is lexical and needs no registered policy: credential stores, core -/// system roots, `..` traversal and NUL bytes. It holds whether or not the host's +/// system roots, parent-directory traversal (`..` and the forms Windows normalizes to it, +/// such as `.. ` and `...`) and NUL bytes. It holds whether or not the host's /// discretionary policy is enabled. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/crates/tinysecurity-policy/src/floor_tests.rs b/crates/tinysecurity-policy/src/floor_tests.rs index 71280c8e..ca0714b5 100644 --- a/crates/tinysecurity-policy/src/floor_tests.rs +++ b/crates/tinysecurity-policy/src/floor_tests.rs @@ -109,3 +109,27 @@ fn file_store_subset_stays_within_credential_components() { assert!(CREDENTIAL_COMPONENTS.contains(name), "{name}"); } } + +#[test] +fn windows_normalized_parent_segments_count_as_traversal() { + for path in [ + r"C:\safe\.. \Windows\System32", + r"C:\safe\..\Windows", + r"C:\safe\... \x", + r"C:\safe\.. . \x", + "/work/.. /etc/passwd", + "/work/.../x", + ] { + assert!(floor(path), "{path:?}"); + assert!(crate::protected_path(&PathBuf::from(path)), "{path:?}"); + } + // Ordinary names that merely contain dots stay usable. + for path in [ + "/work/my..file.txt", + "/work/.hidden", + "/work/a. b", + "/work/.config.d", + ] { + assert!(!floor(path), "{path:?}"); + } +} diff --git a/crates/tinysecurity-policy/src/path.rs b/crates/tinysecurity-policy/src/path.rs index e0979188..f2094f9a 100644 --- a/crates/tinysecurity-policy/src/path.rs +++ b/crates/tinysecurity-policy/src/path.rs @@ -46,6 +46,11 @@ pub(super) const WINDOWS_SYSTEM_ROOTS: &[&str] = &[ "program files (x86)", "programdata", ]; +/// A parent-directory segment, including the forms Windows normalizes to `..` +/// (trailing spaces and dots, as in `.. ` or `...`). Checked lexically on every OS. +pub(super) fn parent_segment(segment: &str) -> bool { + segment.starts_with("..") && segment.trim_end_matches([' ', '.']).is_empty() +} pub(super) fn denial_segment(segment: &str) -> &str { segment .split(':') @@ -61,7 +66,10 @@ pub(super) fn floor(path: &str) -> bool { let segments: Vec<_> = normalized.split('/').filter(|s| !s.is_empty()).collect(); // Both slash dialects are checked on every OS. The additional macOS // /private aliases are protected before canonicalization too. - if path.contains('\0') || segments.contains(&"..") || credential_store(&segments) { + if path.contains('\0') + || segments.iter().any(|segment| parent_segment(segment)) + || credential_store(&segments) + { return true; } CRITICAL_ROOTS diff --git a/crates/tinysecurity-policy/src/path_scope.rs b/crates/tinysecurity-policy/src/path_scope.rs index 1edf456c..244246cd 100644 --- a/crates/tinysecurity-policy/src/path_scope.rs +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -35,7 +35,9 @@ pub fn protected_path(path: &Path) -> bool { if normalized.contains('\0') || normalized.contains("..%2f") || normalized.contains("%2f..") - || segments.contains(&"..") + || segments + .iter() + .any(|segment| crate::path::parent_segment(segment)) || crate::path::credential_store(&segments) { return true; diff --git a/crates/tinysecurity-policy/src/scoped_forbidden.rs b/crates/tinysecurity-policy/src/scoped_forbidden.rs index 43effeb6..24a9563c 100644 --- a/crates/tinysecurity-policy/src/scoped_forbidden.rs +++ b/crates/tinysecurity-policy/src/scoped_forbidden.rs @@ -30,7 +30,7 @@ fn expand_home(entry: &str, home: Option<&Path>) -> Option> { } fn traverses(entry: &str) -> bool { - entry.contains('\0') || entry.split(['/', '\\']).any(|part| part == "..") + entry.contains('\0') || entry.split(['/', '\\']).any(crate::path::parent_segment) } fn resolve_entry(entry: &str, action_dir: &Path, home: Option<&Path>) -> Result { diff --git a/crates/tinysecurity-policy/src/scoped_forbidden_tests.rs b/crates/tinysecurity-policy/src/scoped_forbidden_tests.rs index 15d28308..0f0e6b3f 100644 --- a/crates/tinysecurity-policy/src/scoped_forbidden_tests.rs +++ b/crates/tinysecurity-policy/src/scoped_forbidden_tests.rs @@ -110,3 +110,15 @@ fn entries_inside_the_unconditional_floor_are_redundant_noops() { Ok(vec![kept]) ); } + +#[test] +fn windows_normalized_traversal_entries_are_rejected_not_dropped() { + let home = abs("home"); + for entry in ["a/.. /b", "a\\.. \\b", "/abs/.../x", "~/.. ./x"] { + assert_eq!( + effective(&[entry], Some(&home)), + Err(Error::InvalidArguments), + "{entry:?}" + ); + } +}