security_rust/file/
path_traversal.rs1use 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()); }
158}