Skip to content
Open
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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 2024-06-06 - Path Traversal in TS Extractor
**Vulnerability:** A path traversal vulnerability in `crates/flow/src/incremental/extractors/typescript.rs` during manual module path resolution where popping `std::path::Component::ParentDir` could allow an attacker to traverse to restricted directories or files when canonicalize fails.
**Learning:** During manual canonicalization of path components, ignoring `..` entirely when popping isn't sufficient. If the path navigates up from the root or prefix, popping an empty stack leads to bypassing of security boundaries. Moreover, relative paths like `../../a` get wrongly reduced.
**Prevention:** Explicitly block `Component::ParentDir` from popping `Component::RootDir` or `Component::Prefix`. If the components list is empty or its last element is `Component::ParentDir`, the new `Component::ParentDir` must be pushed instead of ignored, to correctly preserve valid relative paths.
10 changes: 6 additions & 4 deletions crates/ast-engine/src/tree_sitter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,9 +553,8 @@ impl ContentExt for String {
let mut bytes = std::mem::take(self).into_bytes();
let original_len = bytes.len();
bytes.splice(safe_start..safe_end, full_inserted);
*self = Self::from_utf8(bytes).unwrap_or_else(|e| {
Self::from_utf8_lossy(&e.into_bytes()).into_owned()
});
*self = Self::from_utf8(bytes)
.unwrap_or_else(|e| Self::from_utf8_lossy(&e.into_bytes()).into_owned());

// We calculate new_end_byte using the difference in the new overall string length
// to correctly align the end offset, taking any potential replacement bytes from
Expand Down Expand Up @@ -791,7 +790,10 @@ mod test {

let tree2 = parse_lang(|p| p.parse(&src, Some(&tree)), &Tsx.get_ts_language())?;
let fresh_tree = parse(&src)?;
assert_eq!(tree2.root_node().to_sexp(), fresh_tree.root_node().to_sexp());
assert_eq!(
tree2.root_node().to_sexp(),
fresh_tree.root_node().to_sexp()
);
Ok(())
}
}
15 changes: 14 additions & 1 deletion crates/flow/src/incremental/extractors/typescript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,20 @@ impl TypeScriptDependencyExtractor {
for component in resolved.components() {
match component {
std::path::Component::ParentDir => {
components.pop();
// SECURITY: Prevent path traversal vulnerabilities by checking the last component
// before popping. We shouldn't pop RootDir or Prefix components.
// If the component list is empty or the last component is already ParentDir,
// we must push ParentDir to preserve relative paths like "../../a".
match components.last() {
Some(std::path::Component::RootDir)
| Some(std::path::Component::Prefix(_)) => {}
Some(std::path::Component::ParentDir) | None => {
components.push(component)
}
_ => {
components.pop();
}
}
Comment on lines 808 to +824
}
std::path::Component::CurDir => {}
_ => components.push(component),
Expand Down
8 changes: 4 additions & 4 deletions crates/rule-engine/src/check_var.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ fn get_vars_from_rules<'r>(rule: &'r Rule, utils: &'r RuleRegistration) -> Rapid
vars
}

fn check_var_in_constraints<'r>(
fn check_var_in_constraints(
mut vars: RapidSet<String>,
constraints: &'r RapidMap<thread_ast_engine::meta_var::MetaVariableID, Rule>,
constraints: &RapidMap<thread_ast_engine::meta_var::MetaVariableID, Rule>,
) -> RResult<RapidSet<String>> {
for rule in constraints.values() {
for var in rule.defined_vars() {
Expand All @@ -125,9 +125,9 @@ fn check_var_in_constraints<'r>(
Ok(vars)
}

fn check_var_in_transform<'r>(
fn check_var_in_transform(
mut vars: RapidSet<String>,
transform: &'r Option<Transform>,
transform: &Option<Transform>,
) -> RResult<RapidSet<String>> {
let Some(transform) = transform else {
return Ok(vars);
Expand Down
6 changes: 5 additions & 1 deletion crates/rule-engine/src/rule/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,11 @@ impl Rule {

pub fn defined_vars(&self) -> RapidSet<String> {
match self {
Rule::Pattern(p) => p.defined_vars().into_iter().map(|s| s.to_string()).collect(),
Rule::Pattern(p) => p
.defined_vars()
.into_iter()
.map(|s| s.to_string())
.collect(),
Rule::Kind(_) => RapidSet::default(),
Rule::Regex(_) => RapidSet::default(),
Rule::NthChild(n) => n.defined_vars(),
Expand Down
5 changes: 1 addition & 4 deletions crates/rule-engine/src/rule/referent_rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,7 @@ impl<R> Clone for Registration<R> {

impl<R> Registration<R> {
fn read(&self) -> Arc<RapidMap<String, R>> {
self.0
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
self.0.read().unwrap_or_else(|e| e.into_inner()).clone()
}
pub(crate) fn contains_key(&self, key: &str) -> bool {
self.read().contains_key(key)
Expand Down