diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..d956618 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2025-05-15 - [Path Traversal in Manual Path Normalization] +**Vulnerability:** Manual path resolution using `components.pop()` on `std::path::Component::ParentDir` allowed path traversal. If a path like `../../a` was parsed, `components.pop()` on an empty `Vec` did nothing, turning the path into `a` instead of preserving the parent traversal. It could also pop `RootDir` or `Prefix` components, changing absolute paths to relative ones or traversing beyond intended roots. +**Learning:** `std::path::Component` normalization must handle empty lists and consecutive `ParentDir` components by pushing them instead of ignoring them. It must also explicitly avoid popping `RootDir` or `Prefix` components to prevent escaping virtual file systems or simulated root directories. +**Prevention:** Explicitly check if the `components` list is empty, if the last component is `ParentDir`, or if the last component is `RootDir` / `Prefix` before calling `pop()`. diff --git a/crates/flow/src/incremental/extractors/typescript.rs b/crates/flow/src/incremental/extractors/typescript.rs index 1bdda4e..52e22d9 100644 --- a/crates/flow/src/incremental/extractors/typescript.rs +++ b/crates/flow/src/incremental/extractors/typescript.rs @@ -808,7 +808,18 @@ impl TypeScriptDependencyExtractor { for component in resolved.components() { match component { std::path::Component::ParentDir => { - components.pop(); + let is_empty = components.is_empty(); + let last_is_parent = matches!(components.last(), Some(std::path::Component::ParentDir)); + let last_is_root_or_prefix = matches!( + components.last(), + Some(std::path::Component::RootDir) | Some(std::path::Component::Prefix(_)) + ); + + if is_empty || last_is_parent { + components.push(component); + } else if !last_is_root_or_prefix { + components.pop(); + } } std::path::Component::CurDir => {} _ => components.push(component),