Skip to main content

waf_detection/
lfi_rfi.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, Rule};
9
10// ── rules ─────────────────────────────────────────────────────────────────────
11//
12// Scope (deliberately non-overlapping with neighbouring modules to avoid
13// double-counting in the cumulative score):
14//   - Path Traversal owns filesystem path manipulation (`../`, /etc/passwd,
15//     null-byte, UNC). This module does NOT re-detect those.
16//   - SSRF (Module 4) owns the server fetching attacker URLs (metadata IPs,
17//     localhost, gopher://, dict://). This module owns *code/script inclusion*.
18// So LFI/RFI here = inclusion MECHANISMS: PHP/stream wrappers + remote inclusion
19// of a script.
20//
21// Patterns target the normalizer's OUTPUT (Fase 2): query, body AND cookie values
22// share the same canonicalization (percent-decode, double-encoding-aware, + NFKC),
23// so an encoded wrapper in any of them is resolved before inspection. All tokens
24// are ASCII (NFKC-stable). See ARCHITECTURE.md §6 for the shared decode pass.
25
26pub static LFI_RFI_RULES: &[Rule] = &[
27    Rule {
28        id: "lfi-stream-wrapper",
29        // PHP / stream wrappers used to read, execute or smuggle content.
30        pattern: r"(?i)(?:php|phar|zip|glob|expect|file|data)://",
31        severity: Severity::Critical,
32        paranoia: 1,
33    },
34    Rule {
35        id: "lfi-filter-chain",
36        // php://filter conversion chain for source disclosure (distinct token
37        // from the wrapper itself, so both legitimately contribute).
38        pattern: r"(?i)convert\.(?:base64-encode|base64-decode|quoted-printable-encode)",
39        severity: Severity::Critical,
40        paranoia: 1,
41    },
42    Rule {
43        id: "lfi-data-base64",
44        // data: URI carrying a base64 payload (code inclusion via data wrapper).
45        pattern: r"(?i)data:[^,]*;base64,",
46        severity: Severity::Warning,
47        paranoia: 2,
48    },
49    Rule {
50        id: "rfi-remote-script",
51        // Remote URL pointing at an executable script — classic RFI.
52        pattern: r"(?i)(?:https?|ftp)://\S+\.(?:php|phtml|phar|asp|aspx|jsp)\b",
53        severity: Severity::Warning,
54        paranoia: 2,
55    },
56    Rule {
57        id: "rfi-remote-url",
58        // Bare remote URL in a parameter. Very FP-prone (any redirect/link
59        // param), so Notice/PL3 only and non-blocking on its own.
60        pattern: r"(?i)(?:https?|ftp)://",
61        severity: Severity::Notice,
62        paranoia: 3,
63    },
64];
65
66// ── module ────────────────────────────────────────────────────────────────────
67
68#[derive(Default)]
69pub struct LfiRfiModule {
70    rule_set: Option<RegexSet>,
71    /// Rules active at the configured paranoia level, index-aligned with `rule_set`.
72    active_rules: Vec<&'static Rule>,
73}
74
75impl LfiRfiModule {
76    pub fn new() -> Self {
77        Self::default()
78    }
79}
80
81impl WafModule for LfiRfiModule {
82    fn id(&self) -> &str {
83        "lfi_rfi"
84    }
85
86    fn phase(&self) -> Phase {
87        Phase::Body
88    }
89
90    fn init(&mut self, cfg: &Config) {
91        let pl = cfg.waf.paranoia_level;
92        self.active_rules = LFI_RFI_RULES.iter().filter(|r| r.paranoia <= pl).collect();
93        self.rule_set = Some(
94            RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
95                .expect("LFI/RFI rule compilation failed — check patterns at startup"),
96        );
97    }
98
99    fn inspect(&self, ctx: &RequestContext) -> Decision {
100        let Some(rule_set) = &self.rule_set else {
101            return Decision::Allow;
102        };
103
104        let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
105        let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
106        let body_vals = body_str_values(&ctx.normalized.body);
107        let body = body_vals.iter().map(String::as_str);
108        let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);
109
110        let matched = all_matches(rule_set, query.chain(cookies).chain(body).chain(derived));
111        if matched.is_empty() {
112            return Decision::Allow;
113        }
114
115        let items: Vec<ScoreItem> = matched
116            .iter()
117            .map(|&idx| {
118                let rule = self.active_rules[idx];
119                warn!(
120                    request_id = %ctx.request_id,
121                    rule_id = %rule.id,
122                    severity = ?rule.severity,
123                    "lfi/rfi detection"
124                );
125                ScoreItem {
126                    rule_id: rule.id.to_string(),
127                    severity: rule.severity,
128                }
129            })
130            .collect();
131
132        Decision::Scores(items)
133    }
134}