Skip to main content

shell_sanitize_rules/
path_traversal.rs

1use shell_sanitize::{Rule, RuleResult, RuleViolation};
2
3/// Rejects input containing path traversal patterns.
4///
5/// Detects:
6/// - `..` components (parent directory traversal)
7/// - Absolute paths (`/` prefix on Unix, drive letters on Windows)
8///
9/// This is a **lexical check** — no filesystem access is performed.
10/// URL-encoded sequences (e.g. `%2e%2e`) or other encodings are **not**
11/// decoded before inspection. Callers must decode the input before
12/// passing it to this rule if encoded traversal is a concern.
13///
14/// # Rationale
15///
16/// Path traversal allows escaping a base directory:
17/// ```text
18/// ../../etc/passwd
19/// /absolute/path/escape
20/// ```
21pub struct PathTraversalRule {
22    /// Whether to reject absolute paths.
23    deny_absolute: bool,
24}
25
26impl Default for PathTraversalRule {
27    fn default() -> Self {
28        Self {
29            deny_absolute: true,
30        }
31    }
32}
33
34impl PathTraversalRule {
35    /// Create a rule that allows absolute paths (only rejects `..`).
36    pub fn allow_absolute() -> Self {
37        Self {
38            deny_absolute: false,
39        }
40    }
41}
42
43impl Rule for PathTraversalRule {
44    fn name(&self) -> &'static str {
45        "path_traversal"
46    }
47
48    fn check(&self, input: &str) -> RuleResult {
49        let mut violations = Vec::new();
50
51        // ASCII-only scan: splitting at '/' and '\\' ensures byte indices
52        // are always valid UTF-8 char boundaries.
53        let bytes = input.as_bytes();
54        let len = bytes.len();
55        let mut start = 0;
56
57        for i in 0..len {
58            if bytes[i] == b'/' || bytes[i] == b'\\' {
59                if i > start && &input[start..i] == ".." {
60                    violations.push(
61                        RuleViolation::new(self.name(), "parent directory traversal `..` found")
62                            .at(start)
63                            .with_fragment(".."),
64                    );
65                }
66                start = i + 1;
67            }
68        }
69        // Last component
70        if start < len && &input[start..] == ".." {
71            violations.push(
72                RuleViolation::new(self.name(), "parent directory traversal `..` found")
73                    .at(start)
74                    .with_fragment(".."),
75            );
76        }
77
78        // Check for absolute path
79        if self.deny_absolute {
80            if input.starts_with('/') {
81                violations.push(
82                    RuleViolation::new(self.name(), "absolute path (starts with `/`) found")
83                        .at(0)
84                        .with_fragment("/"),
85                );
86            }
87            // Windows drive letter: C:\ or C:/
88            if len >= 3
89                && bytes[0].is_ascii_alphabetic()
90                && bytes[1] == b':'
91                && (bytes[2] == b'\\' || bytes[2] == b'/')
92            {
93                violations.push(
94                    RuleViolation::new(self.name(), "absolute path (drive letter) found")
95                        .at(0)
96                        .with_fragment(input[..3].to_string()),
97                );
98            }
99        }
100
101        if violations.is_empty() {
102            Ok(())
103        } else {
104            Err(violations)
105        }
106    }
107}