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::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
7
8/// 强信号:**多级**跨越。单级 `../` 在源码与文档里是相对路径
9/// (`include_str!("../docs/pet.svg")`、`from '../lib'`、`[guide](../docs/x.md)`),
10/// 两级以上才是「跳出目录树」的攻击形态。百分号编码形态留在强档:正常内容
11/// 不会把点号编码成 `%2e`,出现即有人刻意构造。
12static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
13    // 十四条分支合成一条 alternation。flags **不一致**(`(?:\.\./){2,}` 等四条大小写中性,
14    // 其余十条是 `(?i)`),故逐条包在 `(?i:…)` 里,不让 `(?i)` 顺延。
15    Regex::new(
16        &[
17            r"(?:\.\./){2,}",
18            r"|(?:\.\.\\){2,}",
19            r"|(?i:\.\.%2[Ff])",
20            // 反斜杠的百分号编码:`..%5c..%5cwindows%5csystem32`,`..%2f` 早有覆盖,这条漏了
21            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
38/// 弱信号:单级 `../` / `..\`——它既是一次穿透目录的 LFI 载荷,也是每一份源码里
39/// 的相对路径,两者字节相同(`?file=../wp-config.php` 与 `include_str!("../x")`),
40/// 无法用正则区分。报 Low:单条不足以拒绝,聚合分交给调用方。
41static 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    /// 多级跨越仍然是 Critical——这是攻击形态;单级只有 Low。
163    #[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        // 单级:既是 LFI 载荷也是相对路径,字节相同 → Low
177        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            // 真实语料:路径、URL、百分号编码本身都不算穿越
203            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()); // unicode ellipsis
221    }
222}