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::{regex_detect, AttackCategory, DetectionResult, Detector, Severity};
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(&PATTERNS, self.name(), AttackCategory::File, Severity::Critical, "Path traversal attack detected", input)
35    }
36}
37
38#[cfg(test)]
39mod tests {
40    use super::*;
41
42    #[test]
43    fn name_returns_attack_type() {
44        assert_eq!(PathTraversalDetector.name(), "path_traversal");
45    }
46
47    #[test]
48    fn detects_dotdot_traversal() {
49        for payload in [
50            "../../../etc/passwd",
51            "..\\..\\windows\\system32",
52            "a/../../b",
53        ] {
54            let r = PathTraversalDetector
55                .detect(payload)
56                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
57            assert_eq!(r.attack_type, "path_traversal");
58            assert_eq!(r.category, AttackCategory::File);
59            assert_eq!(r.severity, Severity::Critical);
60            assert!(
61                !r.matched_pattern.is_empty(),
62                "matched_pattern empty for {:?}",
63                payload
64            );
65            assert!(
66                r.offset <= payload.len(),
67                "offset out of range for {:?}",
68                payload
69            );
70        }
71    }
72
73    #[test]
74    fn detects_encoded_traversal() {
75        for payload in [
76            "..%2f..%2fetc/passwd",
77            "..%2Fetc/passwd",
78            "%2e%2e%2fetc/passwd",
79            "%2E%2E/win.ini",
80        ] {
81            let r = PathTraversalDetector
82                .detect(payload)
83                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
84            assert!(
85                !r.matched_pattern.is_empty(),
86                "matched_pattern empty for {:?}",
87                payload
88            );
89            assert!(
90                r.offset <= payload.len(),
91                "offset out of range for {:?}",
92                payload
93            );
94        }
95    }
96
97    #[test]
98    fn detects_wrappers_and_null_bytes() {
99        for payload in [
100            "php://filter/convert.base64-encode/resource=config.php",
101            "php://input",
102            "data://text/plain;base64,PD9waHA=",
103            "expect://id",
104            "phar://archive.phar",
105            "zip://archive.zip#a",
106            "glob://*.php",
107            "file%00.php",
108        ] {
109            let r = PathTraversalDetector
110                .detect(payload)
111                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
112            assert!(
113                !r.matched_pattern.is_empty(),
114                "matched_pattern empty for {:?}",
115                payload
116            );
117            assert!(
118                r.offset <= payload.len(),
119                "offset out of range for {:?}",
120                payload
121            );
122        }
123    }
124
125    #[test]
126    fn ignores_benign_inputs() {
127        for input in [
128            "Hello, this is a normal text input.",
129            "etc/passwd",
130            "a.b/c",
131            "..",
132            "...",
133            "php:",
134            "zip:archive.zip",
135            "/etc/passwd",
136        ] {
137            assert!(
138                PathTraversalDetector.detect(input).is_none(),
139                "false positive: {:?}",
140                input
141            );
142        }
143    }
144
145    #[test]
146    fn edge_cases() {
147        assert!(PathTraversalDetector.detect("").is_none());
148        assert!(PathTraversalDetector.detect("   ").is_none());
149        assert!(PathTraversalDetector.detect("…/…/秘密のファイル").is_none()); // unicode ellipsis
150    }
151}