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 regex_detect(
53 std::slice::from_ref(&*STRONG_PATTERNS),
54 self.name(),
55 AttackCategory::File,
56 Severity::Critical,
57 "Path traversal attack detected",
58 input,
59 )
60 .or_else(|| {
61 regex_detect(
62 std::slice::from_ref(&*WEAK_PATTERNS),
63 self.name(),
64 AttackCategory::File,
65 Severity::Low,
66 "Single-level parent directory reference (weak signal)",
67 input,
68 )
69 })
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 #[test]
78 fn name_returns_attack_type() {
79 assert_eq!(PathTraversalDetector.name(), "path_traversal");
80 }
81
82 #[test]
83 fn detects_dotdot_traversal() {
84 for payload in [
85 "../../../etc/passwd",
86 "..\\..\\windows\\system32",
87 "a/../../b",
88 ] {
89 let r = PathTraversalDetector
90 .detect(payload)
91 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
92 assert_eq!(r.attack_type, "path_traversal");
93 assert_eq!(r.category, AttackCategory::File);
94 assert_eq!(r.severity, Severity::Critical);
95 assert!(
96 !r.matched_pattern.is_empty(),
97 "matched_pattern empty for {:?}",
98 payload
99 );
100 assert!(
101 r.offset <= payload.len(),
102 "offset out of range for {:?}",
103 payload
104 );
105 }
106 }
107
108 #[test]
109 fn detects_encoded_traversal() {
110 for payload in [
111 "..%2f..%2fetc/passwd",
112 "..%2Fetc/passwd",
113 "%2e%2e%2fetc/passwd",
114 "%2E%2E/win.ini",
115 "..%5c..%5cwindows%5csystem32",
116 "..%5C..%5Cwindows%5Csystem32",
117 ] {
118 let r = PathTraversalDetector
119 .detect(payload)
120 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
121 assert!(
122 !r.matched_pattern.is_empty(),
123 "matched_pattern empty for {:?}",
124 payload
125 );
126 assert!(
127 r.offset <= payload.len(),
128 "offset out of range for {:?}",
129 payload
130 );
131 }
132 }
133
134 #[test]
135 fn detects_wrappers_and_null_bytes() {
136 for payload in [
137 "php://filter/convert.base64-encode/resource=config.php",
138 "php://input",
139 "data://text/plain;base64,PD9waHA=",
140 "expect://id",
141 "phar://archive.phar",
142 "zip://archive.zip#a",
143 "glob://*.php",
144 "file%00.php",
145 ] {
146 let r = PathTraversalDetector
147 .detect(payload)
148 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
149 assert!(
150 !r.matched_pattern.is_empty(),
151 "matched_pattern empty for {:?}",
152 payload
153 );
154 assert!(
155 r.offset <= payload.len(),
156 "offset out of range for {:?}",
157 payload
158 );
159 }
160 }
161
162 #[test]
164 fn multi_level_is_critical_single_level_is_low() {
165 for payload in [
166 "../../../../etc/passwd",
167 "../../../etc/passwd",
168 "a/../../b",
169 "..\\..\\windows\\system32",
170 ] {
171 let r = PathTraversalDetector
172 .detect(payload)
173 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
174 assert_eq!(r.severity, Severity::Critical, "payload {:?}", payload);
175 }
176 for input in [
178 "../etc/passwd",
179 "include_str!(\"../docs/pet.svg\")",
180 "import { render } from '../lib/render.js'",
181 "[guide](../docs/api.md)",
182 r"C:\Users\erik\..\backup\report.docx",
183 ] {
184 let r = PathTraversalDetector
185 .detect(input)
186 .unwrap_or_else(|| panic!("expected weak detection for {:?}", input));
187 assert_eq!(r.severity, Severity::Low, "input {:?}", input);
188 }
189 }
190
191 #[test]
192 fn ignores_benign_inputs() {
193 for input in [
194 "Hello, this is a normal text input.",
195 "etc/passwd",
196 "a.b/c",
197 "..",
198 "...",
199 "php:",
200 "zip:archive.zip",
201 "/etc/passwd",
202 r"C:\Users\erik\Documents\report.docx",
204 "https://example.com/docs/api/guide",
205 "path%5Csegment%5Cname",
206 "The file is .. hidden and ... is an ellipsis; see docs/readme.md (relative link).",
207 ] {
208 assert!(
209 PathTraversalDetector.detect(input).is_none(),
210 "false positive: {:?}",
211 input
212 );
213 }
214 }
215
216 #[test]
217 fn edge_cases() {
218 assert!(PathTraversalDetector.detect("").is_none());
219 assert!(PathTraversalDetector.detect(" ").is_none());
220 assert!(PathTraversalDetector.detect("…/…/秘密のファイル").is_none()); }
222}