Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
`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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion crates/tinysecurity-bus/src/path_batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
24 changes: 24 additions & 0 deletions crates/tinysecurity-policy/src/floor_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}");
}
}
10 changes: 9 additions & 1 deletion crates/tinysecurity-policy/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
senamakel marked this conversation as resolved.
Resolved
}
pub(super) fn denial_segment(segment: &str) -> &str {
segment
.split(':')
Expand All @@ -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))
Comment thread
senamakel marked this conversation as resolved.
Resolved
|| credential_store(&segments)
{
return true;
}
CRITICAL_ROOTS
Expand Down
4 changes: 3 additions & 1 deletion crates/tinysecurity-policy/src/path_scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-policy/src/scoped_forbidden.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ fn expand_home(entry: &str, home: Option<&Path>) -> Option<Result<PathBuf>> {
}

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<PathBuf> {
Expand Down
12 changes: 12 additions & 0 deletions crates/tinysecurity-policy/src/scoped_forbidden_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
);
}
}
Loading