Skip to main content

waf_detection/
xxe.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 (Fase 10b) ────────────────────────────────────────────────────────────
11//
12// XML External Entity (XXE) injection: a user-supplied XML document that declares
13// an entity / external DTD so the parser dereferences an attacker URL (file://,
14// http://) on the server — file disclosure, SSRF, billion-laughs. gotestwaf
15// xml-injection + community-xxe. The signatures below target XML *markup
16// declarations* that never appear in a plain data value: an entity declaration
17// (`<!ENTITY`), an external DOCTYPE (`<!DOCTYPE … SYSTEM`), and the UTF-7 XML
18// declaration used to smuggle the markup past byte-level filters. All three are
19// unequivocal — block-alone, Critical/PL1 (decision 3). Patterns target the
20// normalizer OUTPUT (Fase 2): the query value round-trips the XML verbatim.
21//
22// PRECISION (no lookaround in the `regex` crate — fixes are structural):
23//   - `<!DOCTYPE … SYSTEM` is anchored to SYSTEM, NOT PUBLIC: an XHTML doctype
24//     `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0…" "http://…dtd">` is benign and
25//     uses PUBLIC, so SYSTEM-only avoids that false positive (the external-PUBLIC
26//     *entity* form `<!ENTITY % x PUBLIC …>` is still caught by `xxe-entity`);
27//   - `[^\[>]*` between DOCTYPE and SYSTEM stops at an internal-subset `[`, so it
28//     never reaches into `<!DOCTYPE x [ … SYSTEM … ]>` (that payload carries an
29//     `<!ENTITY` and is attributed there — one rule per payload).
30//
31// §6-D5 (external XML-Schema inclusion): a BLANKET `xsi:schemaLocation` / `<xs:include>`
32// rule is a SOAP false-positive factory (benign SOAP/XSD carries both constantly). The
33// two rules below instead key on the STRUCTURALLY-ANOMALOUS attack forms that benign XML
34// never uses, so the P2 benign-blocking floor stays 0 (FP-probed against real SOAP / XSD
35// imports / XHTML / noNamespaceSchemaLocation — all clean):
36//   - `<xs:include namespace=…>` — `xs:include` has NO `namespace` attribute per the XSD
37//     spec (that belongs to `xs:import`); a same-namespace include carrying one is malformed.
38//   - `xsi:schemaLocation="<single http(s) URL>"` — legit schemaLocation is ALWAYS a
39//     space-separated `namespace location` PAIR; a lone URL is the attack shape (the legit
40//     single-URL form is the distinct `xsi:noNamespaceSchemaLocation` attribute).
41
42pub static XXE_RULES: &[Rule] = &[
43    Rule {
44        id: "xxe-entity-declaration",
45        // `<!ENTITY` — declaring an XML entity. A data value never does this; in a
46        // request body it is an XXE / billion-laughs attempt. Catches internal,
47        // SYSTEM and PUBLIC external entity declarations alike.
48        pattern: r"(?i)<!ENTITY\b",
49        severity: Severity::Critical,
50        paranoia: 1,
51    },
52    Rule {
53        id: "xxe-doctype-external",
54        // `<!DOCTYPE … SYSTEM` — an external DTD reference. SYSTEM-only (not PUBLIC)
55        // so legacy XHTML doctypes (which use PUBLIC) do not false-positive; the
56        // `[^\[>]*` body stops before an internal subset `[`.
57        pattern: r"(?i)<!DOCTYPE\b[^\[>]*\bSYSTEM\b",
58        severity: Severity::Critical,
59        paranoia: 1,
60    },
61    Rule {
62        id: "xxe-utf7-encoding",
63        // `encoding="UTF-7"` in the XML declaration — a charset-smuggling evasion
64        // (the real `<!DOCTYPE`/`<!ENTITY` markup is UTF-7-encoded to slip past
65        // byte-level filters). No modern XML legitimately declares UTF-7.
66        pattern: r#"(?i)encoding\s*=\s*["']?\+?utf-?7\b"#,
67        severity: Severity::Critical,
68        paranoia: 1,
69    },
70    // ── §6-D5: external XML-Schema inclusion (anomalous-form-anchored, no SOAP FP) ──
71    Rule {
72        id: "xxe-xs-include-namespace",
73        // `<xs:include … namespace=…>` — malformed: `xs:include` is same-namespace, it
74        // takes only `schemaLocation`; a `namespace` attr means an attacker-style remote
75        // include (gotestwaf xml-injection). Legit `xs:import` (which DOES take namespace)
76        // is unaffected — the anchor is the element name `xs:include`.
77        pattern: r"(?i)<xs:include\b[^>]*\bnamespace\s*=",
78        severity: Severity::Critical,
79        paranoia: 1,
80    },
81    Rule {
82        id: "xxe-schemalocation-single-url",
83        // `xsi:schemaLocation="<single http(s) URL>"` — legit schemaLocation is a
84        // space-separated `namespace location` PAIR, so a lone URL (no whitespace before
85        // the closing quote) is the external-schema attack. `xsi:noNamespaceSchemaLocation`
86        // (the legit single-URL attribute) is a different name and does NOT match.
87        pattern: r#"(?i)\bxsi:schemalocation\s*=\s*["']\s*https?://[^"'\s]*\s*["']"#,
88        severity: Severity::Critical,
89        paranoia: 1,
90    },
91];
92
93// ── module ──────────────────────────────────────────────────────────────────────
94
95#[derive(Default)]
96pub struct XxeModule {
97    rule_set: Option<RegexSet>,
98    /// Rules active at the configured paranoia level, index-aligned with `rule_set`.
99    active_rules: Vec<&'static Rule>,
100}
101
102impl XxeModule {
103    pub fn new() -> Self {
104        Self::default()
105    }
106}
107
108impl WafModule for XxeModule {
109    fn id(&self) -> &str {
110        "xxe"
111    }
112
113    fn phase(&self) -> Phase {
114        Phase::Body
115    }
116
117    fn init(&mut self, cfg: &Config) {
118        let pl = cfg.waf.paranoia_level;
119        self.active_rules = XXE_RULES.iter().filter(|r| r.paranoia <= pl).collect();
120        self.rule_set = Some(
121            RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
122                .expect("XXE rule compilation failed — check patterns at startup"),
123        );
124    }
125
126    fn inspect(&self, ctx: &RequestContext) -> Decision {
127        let Some(rule_set) = &self.rule_set else {
128            return Decision::Allow;
129        };
130
131        let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
132        let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
133        let body_vals = body_str_values(&ctx.normalized.body);
134        let body = body_vals.iter().map(String::as_str);
135        let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);
136
137        // URLPath coverage (10c REOPEN, pcap-confirmed): gotestwaf places payloads in the
138        // URL PATH; this module must scan the resolved path too (mirrors rce/xss), else a
139        // path-placed payload bypasses. The prefilter already scans the path (sound).
140        let path = std::iter::once(ctx.normalized.path.as_str());
141        // P1-B: also scan the allowlisted request headers (Referer / X-Forwarded-* / custom x-*).
142        let headers = inspectable_header_values(ctx);
143        let matched = all_matches(rule_set, path.chain(query).chain(cookies).chain(body).chain(derived).chain(headers));
144        if matched.is_empty() {
145            return Decision::Allow;
146        }
147
148        let items: Vec<ScoreItem> = matched
149            .iter()
150            .map(|&idx| {
151                let rule = self.active_rules[idx];
152                warn!(
153                    request_id = %ctx.request_id,
154                    rule_id = %rule.id,
155                    severity = ?rule.severity,
156                    "xxe detection"
157                );
158                ScoreItem {
159                    rule_id: rule.id.to_string(),
160                    severity: rule.severity,
161                }
162            })
163            .collect();
164
165        Decision::Scores(items)
166    }
167}