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