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        // 强档 14 条 + 弱档 2 条,每条分支都要求 `.` / `%` / `:` / NUL 之一:
53        // `../`、`..%2f`、`php://filter`、`%00`、字面 NUL —— 四者皆无 → 不可能命中。
54        // 无点 1MB 文本实测 2.2ms → 0.4ms(release,min-of-5);含 `.` 的文本
55        // (此守卫的第一个 `contains` 即命中)无收益也不额外亏。
56        // 前提只对**当前分支集**成立:新增分支若不要求这四者,这里会变成漏报,
57        // 必须同步放宽守卫。
58        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    /// 多级跨越仍然是 Critical——这是攻击形态;单级只有 Low。
176    #[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        // 单级:既是 LFI 载荷也是相对路径,字节相同 → Low
190        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            // 真实语料:路径、URL、百分号编码本身都不算穿越
216            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        // 无 `.`/`%`/`:`/NUL 的输入在 detect 顶部走 `contains` 快速路径返回。
232        // 长文本:守卫若吞掉命中(或误报),这条先炸。
233        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        // 含 `.`/`:`/`%` 的正常输入绕过守卫进正则,结果与守卫前一致(仍干净)。
241        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()); // unicode ellipsis
260    }
261}