Skip to main content

waf_detection/
path_traversal.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4use regex::RegexSet;
5use tracing::warn;
6use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
7
8use crate::{all_matches, body_str_values, inspectable_header_values, Rule};
9
10// ── rules ─────────────────────────────────────────────────────────────────────
11//
12// Two rule families (see ARCHITECTURE §7 and the Fase 2 normalizer):
13//   - traversal sequences (`../`, `..\`) survive in query/cookie/body but NOT in
14//     `normalized.path`, which the normalizer already resolves (`resolve_path`);
15//   - sensitive target paths survive in `normalized.path` even after the `..`
16//     have been resolved away (e.g. `/app/../../etc/passwd` -> `/etc/passwd`),
17//     so they catch traversal that targeted the URL path itself.
18// Both families run uniformly over all inspected fields; a rule that cannot
19// match a given field (e.g. `../` on the resolved path) simply stays silent.
20
21pub static PATH_TRAVERSAL_RULES: &[Rule] = &[
22    Rule {
23        id: "pt-dotdot-traversal",
24        // Two or more *consecutive* traversal segments (`../../`, `..\..\`,
25        // mixed) — the signature of an actual directory escape. A single `../`
26        // is left to `pt-sensitive-*` (which still flags traversal that reaches a
27        // sensitive target on the resolved path/value): requiring `{2,}` keeps a
28        // benign relative `../` (`docs/../report.pdf`, `../images/logo.png`) Clean
29        // without losing real escapes (`/static/img/../../etc/passwd` has `../../`).
30        // Structural narrowing (no lookahead — the `regex` crate has none). 10b-cont.
31        pattern: r"(?:\.\.[\\/]){2,}",
32        severity: Severity::Critical,
33        paranoia: 1,
34    },
35    Rule {
36        id: "pt-sensitive-unix",
37        // Classic Unix exfiltration targets.
38        pattern: r"(?i)/(?:etc/(?:passwd|shadow|group|hosts)|proc/self|proc/version)",
39        severity: Severity::Critical,
40        paranoia: 1,
41    },
42    Rule {
43        id: "pt-sensitive-windows",
44        // Windows targets; `system32` anchored to a path separator + word
45        // boundary so legit tokens like `system32_dark` don't false-positive.
46        pattern: r"(?i)(?:boot\.ini|win\.ini|[\\/]windows[\\/]|[\\/]system32\b)",
47        severity: Severity::Critical,
48        paranoia: 1,
49    },
50    Rule {
51        id: "pt-null-byte",
52        // NUL byte truncation. Dead on the path (the normalizer strips NUL), but
53        // live on query/body where `%00` is decoded to a real NUL byte.
54        pattern: r"\x00",
55        severity: Severity::Warning,
56        paranoia: 2,
57    },
58    Rule {
59        id: "pt-unc-path",
60        // UNC network path: \\server\share — Windows remote file access. The host
61        // class includes `:` so an IPv6-literal host (`\\::1\c$\…`, localhost over
62        // UNC) matches too; the literal backslashes survive normalization (Fase 2),
63        // only the host token needed widening. gotestwaf path-traversal.
64        pattern: r"(?i)\\\\[a-z0-9_.$:-]+\\",
65        severity: Severity::Notice,
66        paranoia: 3,
67    },
68    Rule {
69        id: "pt-unc-admin-share",
70        // UNC path to an ADMINISTRATIVE share — `\\host\c$\`, `\\host\admin$\`, `\\host\ipc$\`.
71        // The `$`-suffixed share is the attack tell (remote SYSTEM-drive / IPC access, e.g.
72        // `\\::1\c$\users\default\ntuser.dat`); ordinary file shares (`\\srv\public\`) have
73        // no `$` and stay on the Notice-level `pt-unc-path`. Promotes ONLY the admin-share
74        // form to Critical, no FP on legitimate UNC references.
75        pattern: r"(?i)\\\\[a-z0-9_.:-]+\\[a-z]+\$\\",
76        severity: Severity::Critical,
77        paranoia: 1,
78    },
79];
80
81// ── module ────────────────────────────────────────────────────────────────────
82
83#[derive(Default)]
84pub struct PathTraversalModule {
85    rule_set: Option<RegexSet>,
86    /// Rules active at the configured paranoia level, index-aligned with `rule_set`.
87    active_rules: Vec<&'static Rule>,
88}
89
90impl PathTraversalModule {
91    pub fn new() -> Self {
92        Self::default()
93    }
94}
95
96impl WafModule for PathTraversalModule {
97    fn id(&self) -> &str {
98        "path_traversal"
99    }
100
101    fn phase(&self) -> Phase {
102        Phase::RequestLine
103    }
104
105    fn init(&mut self, cfg: &Config) {
106        let pl = cfg.waf.paranoia_level;
107        self.active_rules = PATH_TRAVERSAL_RULES.iter().filter(|r| r.paranoia <= pl).collect();
108        self.rule_set = Some(
109            RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
110                .expect("path-traversal rule compilation failed — check patterns at startup"),
111        );
112    }
113
114    fn inspect(&self, ctx: &RequestContext) -> Decision {
115        let Some(rule_set) = &self.rule_set else {
116            return Decision::Allow;
117        };
118
119        let path = std::iter::once(ctx.normalized.path.as_str());
120        let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
121        let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
122        let body_vals = body_str_values(&ctx.normalized.body);
123        let body = body_vals.iter().map(String::as_str);
124        let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);
125
126        // P1-B: also scan the allowlisted request headers (Referer / X-Forwarded-* / custom x-*).
127        let headers = inspectable_header_values(ctx);
128        let matched = all_matches(rule_set, path.chain(query).chain(cookies).chain(body).chain(derived).chain(headers));
129        if matched.is_empty() {
130            return Decision::Allow;
131        }
132
133        let items: Vec<ScoreItem> = matched
134            .iter()
135            .map(|&idx| {
136                let rule = self.active_rules[idx];
137                warn!(
138                    request_id = %ctx.request_id,
139                    rule_id = %rule.id,
140                    severity = ?rule.severity,
141                    "path-traversal detection"
142                );
143                ScoreItem {
144                    rule_id: rule.id.to_string(),
145                    severity: rule.severity,
146                }
147            })
148            .collect();
149
150        Decision::Scores(items)
151    }
152}