Skip to main content

guard_core_engine/patterns/
xml_xxe.rs

1//! XML/XXE structural matchers ported from
2//! `guard_core/handlers/_suspatterns_xml_xxe.py` (spec 4.0.2).
3
4use super::pyregex::{Candidate, PyRegex};
5
6const SYSTEM_PREFIX: &str = r"<!(?:ENTITY|DOCTYPE)";
7const PUBLIC_EXTERNAL_DTD_RE: &str =
8    r#"<!DOCTYPE[^>\[]+PUBLIC[^>\[]+["']https?://(?!(?:www\.)?w3\.org/)[^"'>]+["'][^>\[]*>"#;
9
10fn compile(source: &str) -> Option<PyRegex> {
11    PyRegex::compile(source, true).ok()
12}
13
14fn find_positions(source: &str, haystack: &str) -> Vec<usize> {
15    compile(source)
16        .map(|re| re.re().find_iter(haystack).map(|m| m.start()).collect())
17        .unwrap_or_default()
18}
19
20fn first_at_or_after(sorted: &[usize], floor: usize) -> Option<usize> {
21    sorted.iter().copied().find(|p| *p >= floor)
22}
23
24fn search_between(re: &PyRegex, haystack: &str, start: usize, end: usize) -> bool {
25    if start >= end || end > haystack.len() {
26        return false;
27    }
28    re.re().is_match(&haystack[start..end])
29}
30
31/// `_xml_system_finditer`: from each `<!ENTITY`/`<!DOCTYPE` to the first `>`
32/// after it, requiring `SYSTEM` inside; overlapping spans are skipped.
33#[must_use]
34pub fn xml_system_finditer(haystack: &str) -> Vec<Candidate> {
35    let Some(gt) = compile(">") else {
36        return Vec::new();
37    };
38    let Some(prefix) = compile(SYSTEM_PREFIX) else {
39        return Vec::new();
40    };
41    let Some(keyword) = compile("SYSTEM") else {
42        return Vec::new();
43    };
44    let ends: Vec<usize> = gt.re().find_iter(haystack).map(|m| m.start()).collect();
45    let mut matches = Vec::new();
46    let mut last_end = 0usize;
47    for prefix_match in prefix.re().find_iter(haystack) {
48        let prefix_start = prefix_match.start();
49        let prefix_end = prefix_match.end();
50        if prefix_start < last_end {
51            continue;
52        }
53        let Some(end) = first_at_or_after(&ends, prefix_end) else {
54            return matches;
55        };
56        last_end = end + 1;
57        if search_between(&keyword, haystack, prefix_end + 1, end) {
58            matches.push(Candidate::new(prefix_start, last_end));
59        }
60    }
61    matches
62}
63
64/// `_xml_internal_entity_finditer`: DOCTYPE position, the first `>`/`[`
65/// boundary must be `[`, then an `<!ENTITY` inside the bracket section.
66#[must_use]
67pub fn xml_internal_entity_finditer(haystack: &str) -> Vec<Candidate> {
68    let boundaries = find_positions(r"[>\[]", haystack);
69    let entities = find_positions("<!ENTITY", haystack);
70    let doctypes = find_positions("<!DOCTYPE", haystack);
71    let mut matches = Vec::new();
72    let mut last_end = 0usize;
73    for prefix_start in doctypes {
74        let prefix_end = prefix_start + "<!DOCTYPE".len();
75        if prefix_start < last_end {
76            continue;
77        }
78        let Some(boundary) = first_at_or_after(&boundaries, prefix_end) else {
79            return matches;
80        };
81        last_end = boundary + 1;
82        if !haystack[boundary..].starts_with('[') {
83            continue;
84        }
85        let Some(entity) = first_at_or_after(&entities, boundary + 1) else {
86            return matches;
87        };
88        last_end = entity + "<!ENTITY".len();
89        matches.push(Candidate::new(prefix_start, last_end));
90    }
91    matches
92}
93
94fn scheme_completion_end(
95    haystack: &str,
96    scheme_start: usize,
97    class12_boundaries: &[usize],
98    class3_boundaries: &[usize],
99) -> Option<usize> {
100    if scheme_start == 0
101        || !haystack[..scheme_start]
102            .chars()
103            .next_back()
104            .is_some_and(|c| c == '"' || c == '\'')
105    {
106        return None;
107    }
108    let scheme = compile(r"https?://")?;
109    let m = scheme.re().find_at(haystack, scheme_start)?;
110    if m.start() != scheme_start {
111        return None;
112    }
113    let scheme_end = m.end();
114    let w3 = compile(r"(?:www\.)?w3\.org/")?;
115    if w3
116        .re()
117        .find_at(haystack, scheme_end)
118        .is_some_and(|m| m.start() == scheme_end)
119    {
120        return None;
121    }
122    quoted_url_end(haystack, scheme_end, class12_boundaries, class3_boundaries)
123}
124
125fn quoted_url_end(
126    haystack: &str,
127    scheme_end: usize,
128    class12_boundaries: &[usize],
129    class3_boundaries: &[usize],
130) -> Option<usize> {
131    let quote2 = first_at_or_after(class3_boundaries, scheme_end)?;
132    if quote2 == scheme_end || haystack[quote2..].starts_with('>') {
133        return None;
134    }
135    let final_boundary = first_at_or_after(class12_boundaries, quote2 + 1)?;
136    haystack[final_boundary..]
137        .starts_with('>')
138        .then_some(final_boundary)
139}
140
141/// `_xml_xxe_public_external_dtd_finditer`.
142///
143/// DOCTYPE before PUBLIC in the same boundary-delimited run (>= 10 chars
144/// apart) plus a quoted http(s):// URL whose quoted form terminates before
145/// the DOCTYPE's final `>`.
146#[must_use]
147pub fn xml_xxe_public_external_dtd_finditer(haystack: &str) -> Vec<Candidate> {
148    let doctype_positions = find_positions("<!DOCTYPE", haystack);
149    let public_positions = find_positions("PUBLIC", haystack);
150    if doctype_positions.is_empty() || public_positions.is_empty() {
151        return Vec::new();
152    }
153    let class12_boundaries = find_positions(r"[>\[]", haystack);
154    let class3_boundaries = find_positions(r#"["'>]"#, haystack);
155    let Some(scheme) = compile(r"https?://") else {
156        return Vec::new();
157    };
158    let mut quote_positions: Vec<usize> = Vec::new();
159    let mut quote_to_final_gt: std::collections::HashMap<usize, usize> =
160        std::collections::HashMap::new();
161    for m in scheme.re().find_iter(haystack) {
162        if let Some(final_gt) =
163            scheme_completion_end(haystack, m.start(), &class12_boundaries, &class3_boundaries)
164        {
165            let quote_pos = m.start() - 1;
166            quote_positions.push(quote_pos);
167            quote_to_final_gt.insert(quote_pos, final_gt);
168        }
169    }
170    if quote_positions.is_empty() {
171        return Vec::new();
172    }
173
174    let mut matches = Vec::new();
175    let mut last_end = 0usize;
176    for public_pos in public_positions {
177        if public_pos < last_end {
178            continue;
179        }
180        // run bounds between class12 boundaries
181        let run_start = class12_boundaries
182            .iter()
183            .copied()
184            .rfind(|p| *p <= public_pos)
185            .map_or(0, |p| p + 1);
186        let run_end = class12_boundaries
187            .iter()
188            .copied()
189            .find(|p| *p > public_pos)
190            .unwrap_or(haystack.len());
191        let Some(doctype_before) = first_at_or_after(&doctype_positions, run_start) else {
192            continue;
193        };
194        if doctype_before >= public_pos.saturating_sub(9) {
195            continue;
196        }
197        let Some(quote1) = first_at_or_after(&quote_positions, public_pos + 7) else {
198            continue;
199        };
200        if quote1 >= run_end {
201            continue;
202        }
203        let Some(final_gt) = quote_to_final_gt.get(&quote1) else {
204            continue;
205        };
206        let candidate = Candidate::new(doctype_before, final_gt + 1);
207        matches.push(candidate);
208        last_end = candidate.end;
209    }
210    matches
211}
212
213#[must_use]
214pub const fn public_external_dtd_source() -> &'static str {
215    PUBLIC_EXTERNAL_DTD_RE
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn system_entity_requires_system_keyword() {
224        let hits = xml_system_finditer(r#"<!ENTITY xxe SYSTEM "file:///etc/passwd">"#);
225        assert_eq!(hits.len(), 1);
226        assert!(xml_system_finditer(r#"<!ENTITY xxe "plain">"#).is_empty());
227    }
228
229    #[test]
230    fn internal_entity_requires_bracket_section() {
231        let text = "<!DOCTYPE foo [<!ENTITY xxe \"bar\">]>";
232        let hits = xml_internal_entity_finditer(text);
233        assert_eq!(hits.len(), 1);
234        assert!(xml_internal_entity_finditer("<!DOCTYPE foo SYSTEM \"x\">").is_empty());
235    }
236
237    #[test]
238    fn public_external_dtd_rejects_w3_org() {
239        let good = r#"<!DOCTYPE foo PUBLIC "-//X//DTD//EN" "http://evil.example/dtd">"#;
240        assert_eq!(xml_xxe_public_external_dtd_finditer(good).len(), 1);
241        let w3 = r#"<!DOCTYPE foo PUBLIC "-//W3C//DTD//EN" "http://www.w3.org/dtd">"#;
242        assert!(xml_xxe_public_external_dtd_finditer(w3).is_empty());
243    }
244}