1use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
7
8static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
13 Regex::new(
16 &[
17 r"(?:\.\./){2,}",
18 r"|(?:\.\.\\){2,}",
19 r"|(?i:\.\.%2[Ff])",
20 r"|(?i:\.\.%5[Cc])",
22 r"|(?i:%2[Ee]%2[Ee])",
23 r"|(?i:php://filter)",
24 r"|(?i:php://input)",
25 r"|(?i:data://)",
26 r"|(?i:expect://)",
27 r"|(?i:phar://)",
28 r"|(?i:zip://)",
29 r"|(?i:glob://)",
30 r"|%00",
31 r"|\x00",
32 ]
33 .concat(),
34 )
35 .unwrap()
36});
37
38static WEAK_PATTERNS: LazyLock<Regex> =
42 LazyLock::new(|| Regex::new(r"\.\./|\.\.\\").unwrap());
43
44pub struct PathTraversalDetector;
45
46impl Detector for PathTraversalDetector {
47 fn name(&self) -> &'static str {
48 "path_traversal"
49 }
50
51 fn detect(&self, input: &str) -> Option<DetectionResult> {
52 if !input.contains('.')
59 && !input.contains('%')
60 && !input.contains(':')
61 && !input.contains('\0')
62 {
63 return None;
64 }
65 regex_detect(
66 std::slice::from_ref(&*STRONG_PATTERNS),
67 self.name(),
68 AttackCategory::File,
69 Severity::Critical,
70 "Path traversal attack detected",
71 input,
72 )
73 .or_else(|| {
74 regex_detect(
75 std::slice::from_ref(&*WEAK_PATTERNS),
76 self.name(),
77 AttackCategory::File,
78 Severity::Low,
79 "Single-level parent directory reference (weak signal)",
80 input,
81 )
82 })
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn name_returns_attack_type() {
92 assert_eq!(PathTraversalDetector.name(), "path_traversal");
93 }
94
95 #[test]
96 fn detects_dotdot_traversal() {
97 for payload in [
98 "../../../etc/passwd",
99 "..\\..\\windows\\system32",
100 "a/../../b",
101 ] {
102 let r = PathTraversalDetector
103 .detect(payload)
104 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
105 assert_eq!(r.attack_type, "path_traversal");
106 assert_eq!(r.category, AttackCategory::File);
107 assert_eq!(r.severity, Severity::Critical);
108 assert!(
109 !r.matched_pattern.is_empty(),
110 "matched_pattern empty for {:?}",
111 payload
112 );
113 assert!(
114 r.offset <= payload.len(),
115 "offset out of range for {:?}",
116 payload
117 );
118 }
119 }
120
121 #[test]
122 fn detects_encoded_traversal() {
123 for payload in [
124 "..%2f..%2fetc/passwd",
125 "..%2Fetc/passwd",
126 "%2e%2e%2fetc/passwd",
127 "%2E%2E/win.ini",
128 "..%5c..%5cwindows%5csystem32",
129 "..%5C..%5Cwindows%5Csystem32",
130 ] {
131 let r = PathTraversalDetector
132 .detect(payload)
133 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
134 assert!(
135 !r.matched_pattern.is_empty(),
136 "matched_pattern empty for {:?}",
137 payload
138 );
139 assert!(
140 r.offset <= payload.len(),
141 "offset out of range for {:?}",
142 payload
143 );
144 }
145 }
146
147 #[test]
148 fn detects_wrappers_and_null_bytes() {
149 for payload in [
150 "php://filter/convert.base64-encode/resource=config.php",
151 "php://input",
152 "data://text/plain;base64,PD9waHA=",
153 "expect://id",
154 "phar://archive.phar",
155 "zip://archive.zip#a",
156 "glob://*.php",
157 "file%00.php",
158 ] {
159 let r = PathTraversalDetector
160 .detect(payload)
161 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
162 assert!(
163 !r.matched_pattern.is_empty(),
164 "matched_pattern empty for {:?}",
165 payload
166 );
167 assert!(
168 r.offset <= payload.len(),
169 "offset out of range for {:?}",
170 payload
171 );
172 }
173 }
174
175 #[test]
177 fn multi_level_is_critical_single_level_is_low() {
178 for payload in [
179 "../../../../etc/passwd",
180 "../../../etc/passwd",
181 "a/../../b",
182 "..\\..\\windows\\system32",
183 ] {
184 let r = PathTraversalDetector
185 .detect(payload)
186 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
187 assert_eq!(r.severity, Severity::Critical, "payload {:?}", payload);
188 }
189 for input in [
191 "../etc/passwd",
192 "include_str!(\"../docs/pet.svg\")",
193 "import { render } from '../lib/render.js'",
194 "[guide](../docs/api.md)",
195 r"C:\Users\erik\..\backup\report.docx",
196 ] {
197 let r = PathTraversalDetector
198 .detect(input)
199 .unwrap_or_else(|| panic!("expected weak detection for {:?}", input));
200 assert_eq!(r.severity, Severity::Low, "input {:?}", input);
201 }
202 }
203
204 #[test]
205 fn ignores_benign_inputs() {
206 for input in [
207 "Hello, this is a normal text input.",
208 "etc/passwd",
209 "a.b/c",
210 "..",
211 "...",
212 "php:",
213 "zip:archive.zip",
214 "/etc/passwd",
215 r"C:\Users\erik\Documents\report.docx",
217 "https://example.com/docs/api/guide",
218 "path%5Csegment%5Cname",
219 "The file is .. hidden and ... is an ellipsis; see docs/readme.md (relative link).",
220 ] {
221 assert!(
222 PathTraversalDetector.detect(input).is_none(),
223 "false positive: {:?}",
224 input
225 );
226 }
227 }
228
229 #[test]
230 fn separator_free_long_input_stays_clean() {
231 let clean = "The quick brown fox jumps over the lazy dog and then walks back home again "
234 .repeat(1000);
235 assert!(PathTraversalDetector.detect(&clean).is_none());
236 }
237
238 #[test]
239 fn separator_bearing_benign_inputs_still_scanned() {
240 for input in [
242 "www.example.com",
243 "key: value",
244 "discount 20% off",
245 "See chapter 3.2 for details",
246 ] {
247 assert!(
248 PathTraversalDetector.detect(input).is_none(),
249 "false positive: {:?}",
250 input
251 );
252 }
253 }
254
255 #[test]
256 fn edge_cases() {
257 assert!(PathTraversalDetector.detect("").is_none());
258 assert!(PathTraversalDetector.detect(" ").is_none());
259 assert!(PathTraversalDetector.detect("…/…/秘密のファイル").is_none()); }
261}