Skip to main content

security_rust/file/
path_traversal.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
7
8static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
9    vec![
10        Regex::new(r"\.\./").unwrap(),
11        Regex::new(r"\.\.\\").unwrap(),
12        Regex::new(r"(?i)\.\.%2[Ff]").unwrap(),
13        Regex::new(r"(?i)%2[Ee]%2[Ee]").unwrap(),
14        Regex::new(r"(?i)php://filter").unwrap(),
15        Regex::new(r"(?i)php://input").unwrap(),
16        Regex::new(r"(?i)data://").unwrap(),
17        Regex::new(r"(?i)expect://").unwrap(),
18        Regex::new(r"(?i)phar://").unwrap(),
19        Regex::new(r"(?i)zip://").unwrap(),
20        Regex::new(r"(?i)glob://").unwrap(),
21        Regex::new(r"%00").unwrap(),
22        Regex::new(r"\x00").unwrap(),
23    ]
24});
25
26pub struct PathTraversalDetector;
27
28impl Detector for PathTraversalDetector {
29    fn name(&self) -> &'static str {
30        "path_traversal"
31    }
32
33    fn detect(&self, input: &str) -> Option<DetectionResult> {
34        regex_detect(
35            &PATTERNS,
36            self.name(),
37            AttackCategory::File,
38            Severity::Critical,
39            "Path traversal attack detected",
40            input,
41        )
42    }
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48
49    #[test]
50    fn name_returns_attack_type() {
51        assert_eq!(PathTraversalDetector.name(), "path_traversal");
52    }
53
54    #[test]
55    fn detects_dotdot_traversal() {
56        for payload in [
57            "../../../etc/passwd",
58            "..\\..\\windows\\system32",
59            "a/../../b",
60        ] {
61            let r = PathTraversalDetector
62                .detect(payload)
63                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
64            assert_eq!(r.attack_type, "path_traversal");
65            assert_eq!(r.category, AttackCategory::File);
66            assert_eq!(r.severity, Severity::Critical);
67            assert!(
68                !r.matched_pattern.is_empty(),
69                "matched_pattern empty for {:?}",
70                payload
71            );
72            assert!(
73                r.offset <= payload.len(),
74                "offset out of range for {:?}",
75                payload
76            );
77        }
78    }
79
80    #[test]
81    fn detects_encoded_traversal() {
82        for payload in [
83            "..%2f..%2fetc/passwd",
84            "..%2Fetc/passwd",
85            "%2e%2e%2fetc/passwd",
86            "%2E%2E/win.ini",
87        ] {
88            let r = PathTraversalDetector
89                .detect(payload)
90                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
91            assert!(
92                !r.matched_pattern.is_empty(),
93                "matched_pattern empty for {:?}",
94                payload
95            );
96            assert!(
97                r.offset <= payload.len(),
98                "offset out of range for {:?}",
99                payload
100            );
101        }
102    }
103
104    #[test]
105    fn detects_wrappers_and_null_bytes() {
106        for payload in [
107            "php://filter/convert.base64-encode/resource=config.php",
108            "php://input",
109            "data://text/plain;base64,PD9waHA=",
110            "expect://id",
111            "phar://archive.phar",
112            "zip://archive.zip#a",
113            "glob://*.php",
114            "file%00.php",
115        ] {
116            let r = PathTraversalDetector
117                .detect(payload)
118                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
119            assert!(
120                !r.matched_pattern.is_empty(),
121                "matched_pattern empty for {:?}",
122                payload
123            );
124            assert!(
125                r.offset <= payload.len(),
126                "offset out of range for {:?}",
127                payload
128            );
129        }
130    }
131
132    #[test]
133    fn ignores_benign_inputs() {
134        for input in [
135            "Hello, this is a normal text input.",
136            "etc/passwd",
137            "a.b/c",
138            "..",
139            "...",
140            "php:",
141            "zip:archive.zip",
142            "/etc/passwd",
143        ] {
144            assert!(
145                PathTraversalDetector.detect(input).is_none(),
146                "false positive: {:?}",
147                input
148            );
149        }
150    }
151
152    #[test]
153    fn edge_cases() {
154        assert!(PathTraversalDetector.detect("").is_none());
155        assert!(PathTraversalDetector.detect("   ").is_none());
156        assert!(PathTraversalDetector.detect("…/…/秘密のファイル").is_none()); // unicode ellipsis
157    }
158}