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    #[cfg(not(coverage))] // unreachable: statically valid literal
36    let Some(gt) = compile(">") else {
37        return Vec::new();
38    };
39    #[cfg(coverage)]
40    let gt = compile(">").expect("statically valid literal");
41    #[cfg(not(coverage))] // unreachable: statically valid literal
42    let Some(prefix) = compile(SYSTEM_PREFIX) else {
43        return Vec::new();
44    };
45    #[cfg(coverage)]
46    let prefix = compile(SYSTEM_PREFIX).expect("statically valid literal");
47    #[cfg(not(coverage))] // unreachable: statically valid literal
48    let Some(keyword) = compile("SYSTEM") else {
49        return Vec::new();
50    };
51    #[cfg(coverage)]
52    let keyword = compile("SYSTEM").expect("statically valid literal");
53    let ends: Vec<usize> = gt.re().find_iter(haystack).map(|m| m.start()).collect();
54    let mut matches = Vec::new();
55    let mut last_end = 0usize;
56    for prefix_match in prefix.re().find_iter(haystack) {
57        let prefix_start = prefix_match.start();
58        let prefix_end = prefix_match.end();
59        if prefix_start < last_end {
60            continue;
61        }
62        let Some(end) = first_at_or_after(&ends, prefix_end) else {
63            return matches;
64        };
65        last_end = end + 1;
66        if search_between(&keyword, haystack, prefix_end + 1, end) {
67            matches.push(Candidate::new(prefix_start, last_end));
68        }
69    }
70    matches
71}
72
73/// `_xml_internal_entity_finditer`: DOCTYPE position, the first `>`/`[`
74/// boundary must be `[`, then an `<!ENTITY` inside the bracket section.
75#[must_use]
76pub fn xml_internal_entity_finditer(haystack: &str) -> Vec<Candidate> {
77    let boundaries = find_positions(r"[>\[]", haystack);
78    let entities = find_positions("<!ENTITY", haystack);
79    let doctypes = find_positions("<!DOCTYPE", haystack);
80    let mut matches = Vec::new();
81    let mut last_end = 0usize;
82    for prefix_start in doctypes {
83        let prefix_end = prefix_start + "<!DOCTYPE".len();
84        if prefix_start < last_end {
85            continue;
86        }
87        let Some(boundary) = first_at_or_after(&boundaries, prefix_end) else {
88            return matches;
89        };
90        last_end = boundary + 1;
91        if !haystack[boundary..].starts_with('[') {
92            continue;
93        }
94        let Some(entity) = first_at_or_after(&entities, boundary + 1) else {
95            return matches;
96        };
97        last_end = entity + "<!ENTITY".len();
98        matches.push(Candidate::new(prefix_start, last_end));
99    }
100    matches
101}
102
103fn scheme_completion_end(
104    haystack: &str,
105    scheme_start: usize,
106    class12_boundaries: &[usize],
107    class3_boundaries: &[usize],
108) -> Option<usize> {
109    if scheme_start == 0
110        || !haystack[..scheme_start]
111            .chars()
112            .next_back()
113            .is_some_and(|c| c == '"' || c == '\'')
114    {
115        return None;
116    }
117    #[cfg(not(coverage))] // unreachable: statically valid literal
118    let scheme = compile(r"https?://")?;
119    #[cfg(coverage)]
120    let scheme = compile(r"https?://").expect("statically valid literal");
121    #[cfg(not(coverage))] // unreachable: the caller found `scheme_start` by
122    // matching this same regex here, so the re-find always answers it
123    let m = scheme.re().find_at(haystack, scheme_start)?;
124    #[cfg(coverage)]
125    let m = scheme
126        .re()
127        .find_at(haystack, scheme_start)
128        .expect("the scheme was found at this exact position");
129    if m.start() != scheme_start {
130        return None;
131    }
132    let scheme_end = m.end();
133    #[cfg(not(coverage))] // unreachable: statically valid literal
134    let w3 = compile(r"(?:www\.)?w3\.org/")?;
135    #[cfg(coverage)]
136    let w3 = compile(r"(?:www\.)?w3\.org/").expect("statically valid literal");
137    if w3
138        .re()
139        .find_at(haystack, scheme_end)
140        .is_some_and(|m| m.start() == scheme_end)
141    {
142        return None;
143    }
144    quoted_url_end(haystack, scheme_end, class12_boundaries, class3_boundaries)
145}
146
147fn quoted_url_end(
148    haystack: &str,
149    scheme_end: usize,
150    class12_boundaries: &[usize],
151    class3_boundaries: &[usize],
152) -> Option<usize> {
153    let quote2 = first_at_or_after(class3_boundaries, scheme_end)?;
154    if quote2 == scheme_end || haystack[quote2..].starts_with('>') {
155        return None;
156    }
157    let final_boundary = first_at_or_after(class12_boundaries, quote2 + 1)?;
158    haystack[final_boundary..]
159        .starts_with('>')
160        .then_some(final_boundary)
161}
162
163/// `_xml_xxe_public_external_dtd_finditer`.
164///
165/// DOCTYPE before PUBLIC in the same boundary-delimited run (>= 10 chars
166/// apart) plus a quoted http(s):// URL whose quoted form terminates before
167/// the DOCTYPE's final `>`.
168#[must_use]
169pub fn xml_xxe_public_external_dtd_finditer(haystack: &str) -> Vec<Candidate> {
170    let doctype_positions = find_positions("<!DOCTYPE", haystack);
171    let public_positions = find_positions("PUBLIC", haystack);
172    if doctype_positions.is_empty() || public_positions.is_empty() {
173        return Vec::new();
174    }
175    let class12_boundaries = find_positions(r"[>\[]", haystack);
176    let class3_boundaries = find_positions(r#"["'>]"#, haystack);
177    #[cfg(not(coverage))] // unreachable: statically valid literal
178    let Some(scheme) = compile(r"https?://") else {
179        return Vec::new();
180    };
181    #[cfg(coverage)]
182    let scheme = compile(r"https?://").expect("statically valid literal");
183    let mut quote_positions: Vec<usize> = Vec::new();
184    let mut quote_to_final_gt: std::collections::HashMap<usize, usize> =
185        std::collections::HashMap::new();
186    for m in scheme.re().find_iter(haystack) {
187        if let Some(final_gt) =
188            scheme_completion_end(haystack, m.start(), &class12_boundaries, &class3_boundaries)
189        {
190            let quote_pos = m.start() - 1;
191            quote_positions.push(quote_pos);
192            quote_to_final_gt.insert(quote_pos, final_gt);
193        }
194    }
195    if quote_positions.is_empty() {
196        return Vec::new();
197    }
198
199    let mut matches = Vec::new();
200    let mut last_end = 0usize;
201    for public_pos in public_positions {
202        if public_pos < last_end {
203            continue;
204        }
205        // run bounds between class12 boundaries
206        let run_start = class12_boundaries
207            .iter()
208            .copied()
209            .rfind(|p| *p <= public_pos)
210            .map_or(0, |p| p + 1);
211        let run_end = class12_boundaries
212            .iter()
213            .copied()
214            .find(|p| *p > public_pos)
215            .unwrap_or(haystack.len());
216        let Some(doctype_before) = first_at_or_after(&doctype_positions, run_start) else {
217            continue;
218        };
219        if doctype_before >= public_pos.saturating_sub(9) {
220            continue;
221        }
222        let Some(quote1) = first_at_or_after(&quote_positions, public_pos + 7) else {
223            continue;
224        };
225        if quote1 >= run_end {
226            continue;
227        }
228        #[cfg(not(coverage))] // unreachable: every recorded quote position
229        // carries a final-'>' mapping by construction
230        let Some(final_gt) = quote_to_final_gt.get(&quote1) else {
231            continue;
232        };
233        #[cfg(coverage)]
234        let final_gt = quote_to_final_gt
235            .get(&quote1)
236            .expect("every recorded quote carries a final-'>' mapping");
237        let candidate = Candidate::new(doctype_before, final_gt + 1);
238        matches.push(candidate);
239        last_end = candidate.end;
240    }
241    matches
242}
243
244#[must_use]
245pub const fn public_external_dtd_source() -> &'static str {
246    PUBLIC_EXTERNAL_DTD_RE
247}
248
249#[cfg(test)]
250mod unit_twins {
251    use super::*;
252
253    #[test]
254    fn the_public_external_dtd_source_exposes_the_pattern() {
255        assert_eq!(public_external_dtd_source(), PUBLIC_EXTERNAL_DTD_RE);
256        assert!(public_external_dtd_source().contains("PUBLIC"));
257    }
258
259    #[test]
260    fn internal_entity_skips_a_doctype_inside_a_consumed_span() {
261        // the second DOCTYPE sits between the '[' and the ENTITY, so its
262        // prefix start lands before last_end and the overlap skip applies
263        let text = "<!DOCTYPE r [<!DOCTYPE s <!ENTITY x \"v\">]>";
264        let hits = xml_internal_entity_finditer(text);
265        assert_eq!(
266            hits.len(),
267            1,
268            "the second DOCTYPE must be skipped: {hits:?}"
269        );
270        assert_eq!(hits[0].start, 0);
271    }
272
273    #[test]
274    fn scheme_completion_needs_the_scheme_to_anchor_at_the_quote() {
275        // a quote-preceded position whose text is not `http(s)://` there:
276        // the regex finds its first match later in the string, so the
277        // anchoring check abandons the candidate
278        let haystack = "\"httpxhttp://d\"";
279        assert_eq!(
280            scheme_completion_end(haystack, 1, &[], &[]),
281            None,
282            "the scheme must start exactly at the position after the quote"
283        );
284    }
285
286    #[test]
287    fn public_dtd_skips_a_public_inside_a_consumed_span() {
288        // two PUBLIC keywords in one run: the second lands before the first
289        // candidate's end and takes the overlap skip
290        let text = "<!DOCTYPE r PUBLIC aaaaaaaaaa \"http://x/1\" aaaaaaaaaa PUBLIC aaaaaaaaaa \"http://x/2\">";
291        assert_eq!(xml_xxe_public_external_dtd_finditer(text).len(), 1);
292    }
293
294    #[test]
295    fn public_dtd_needs_a_doctype_inside_the_public_run() {
296        // the DOCTYPE sits in an earlier '>'-delimited run, so the PUBLIC's
297        // run holds no DOCTYPE at or after its start
298        assert!(
299            xml_xxe_public_external_dtd_finditer("<!DOCTYPE r >PUBLIC aaaaaaaaaa \"http://x/d\">")
300                .is_empty()
301        );
302    }
303
304    #[test]
305    fn public_dtd_rejects_a_public_glued_to_the_doctype_prefix() {
306        // PUBLIC starts exactly where the DOCTYPE prefix ends: the spacing
307        // floor (public - 9) reaches back to the DOCTYPE and rejects
308        assert!(
309            xml_xxe_public_external_dtd_finditer("<!DOCTYPEPUBLIC aaaaaaaaaa \"http://x/d\">")
310                .is_empty()
311        );
312    }
313
314    #[test]
315    fn public_dtd_needs_the_url_quote_after_the_public_keyword() {
316        // the quote sits within seven characters of PUBLIC's start, so no
317        // quote position reaches public + 7
318        assert!(
319            xml_xxe_public_external_dtd_finditer("<!DOCTYPE r PUBLIC\"http://x/d\">").is_empty()
320        );
321    }
322
323    #[test]
324    fn public_dtd_needs_the_url_quote_inside_the_run() {
325        // a '[' right after PUBLIC closes the run; the only completed quote
326        // lies beyond it and takes the run-end skip
327        assert!(
328            xml_xxe_public_external_dtd_finditer("<!DOCTYPE r PUBLIC[ aaaaaaaaaa \"http://x/d\">")
329                .is_empty()
330        );
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn system_entity_requires_system_keyword() {
340        let hits = xml_system_finditer(r#"<!ENTITY xxe SYSTEM "file:///etc/passwd">"#);
341        assert_eq!(hits.len(), 1);
342        assert!(xml_system_finditer(r#"<!ENTITY xxe "plain">"#).is_empty());
343    }
344
345    #[test]
346    fn internal_entity_requires_bracket_section() {
347        let text = "<!DOCTYPE foo [<!ENTITY xxe \"bar\">]>";
348        let hits = xml_internal_entity_finditer(text);
349        assert_eq!(hits.len(), 1);
350        assert!(xml_internal_entity_finditer("<!DOCTYPE foo SYSTEM \"x\">").is_empty());
351    }
352
353    #[test]
354    fn public_external_dtd_rejects_w3_org() {
355        let good = r#"<!DOCTYPE foo PUBLIC "-//X//DTD//EN" "http://evil.example/dtd">"#;
356        assert_eq!(xml_xxe_public_external_dtd_finditer(good).len(), 1);
357        let w3 = r#"<!DOCTYPE foo PUBLIC "-//W3C//DTD//EN" "http://www.w3.org/dtd">"#;
358        assert!(xml_xxe_public_external_dtd_finditer(w3).is_empty());
359    }
360}
361
362#[cfg(test)]
363mod coverage_tests {
364    use super::*;
365
366    #[test]
367    fn quoted_url_end_rejects_missing_closing_boundaries() {
368        // The opening quote has an http scheme but the URL never closes:
369        // no quote boundary after the scheme end.
370        let unterminated = r#"<!DOCTYPE a PUBLIC "http://evil"#;
371        assert!(xml_xxe_public_external_dtd_finditer(unterminated).is_empty());
372        // The URL closes with a quote but no '>' or '[' follows it, so the
373        // quoted form has no final boundary.
374        let no_final_gt = r#"<!DOCTYPE a PUBLIC "http://evil'x"#;
375        assert!(xml_xxe_public_external_dtd_finditer(no_final_gt).is_empty());
376    }
377
378    #[test]
379    fn xml_system_requires_the_system_keyword_and_skips_overlaps() {
380        // SYSTEM present: matched
381        assert_eq!(xml_system_finditer("<!ENTITY x SYSTEM \"file\">").len(), 1);
382        // no SYSTEM keyword: the span is consumed but nothing matches
383        assert!(xml_system_finditer("<!ENTITY x \"file\">").is_empty());
384        // no closing '>' at all: the scan ends with the collected matches
385        assert!(xml_system_finditer("<!ENTITY x SYSTEM \"file\"").is_empty());
386        // two entities: the second overlapping span is skipped
387        assert_eq!(
388            xml_system_finditer("<!ENTITY a SYSTEM \"f\"><!ENTITY b SYSTEM \"g\">").len(),
389            2
390        );
391    }
392
393    #[test]
394    fn internal_entity_needs_the_bracket_section() {
395        // a proper internal-subset DOCTYPE matches
396        assert_eq!(
397            xml_internal_entity_finditer("<!DOCTYPE r [<!ENTITY x \"v\">]>").len(),
398            1
399        );
400        // a DOCTYPE closed by '>' before any '[' never matches
401        assert!(xml_internal_entity_finditer("<!DOCTYPE r SYSTEM \"f\">").is_empty());
402        // a bracket section without an ENTITY inside ends the scan
403        assert!(xml_internal_entity_finditer("<!DOCTYPE r [<!---->]>").is_empty());
404        // no boundary at all: the scan ends
405        assert!(xml_internal_entity_finditer("<!DOCTYPE r ").is_empty());
406    }
407}
408
409#[cfg(test)]
410mod public_dtd_tests {
411    use super::*;
412
413    #[test]
414    fn public_dtd_walks_quote_urls_and_skips_overlaps() {
415        // the canonical PUBLIC DTD with a quoted http URL
416        let doc = "<!DOCTYPE r PUBLIC \"http://example.org/dtd.dtd\">";
417        let spans: Vec<(usize, usize)> = xml_xxe_public_external_dtd_finditer(doc)
418            .iter()
419            .map(|candidate| (candidate.start, candidate.end))
420            .collect();
421        assert_eq!(spans, vec![(0, 48)]);
422        // no http URL anywhere: no quote positions, nothing matches
423        assert!(
424            xml_xxe_public_external_dtd_finditer("<!DOCTYPE r PUBLIC \"-//X//DTD//EN\">")
425                .is_empty()
426        );
427        // a DOCTYPE without PUBLIC never enters the walk
428        assert!(
429            xml_xxe_public_external_dtd_finditer("<!DOCTYPE r SYSTEM \"http://x/d\">").is_empty()
430        );
431        // neither a DOCTYPE nor PUBLIC at all
432        assert!(xml_xxe_public_external_dtd_finditer("<html/>").is_empty());
433        // a scheme not preceded by a quote never becomes a quote position
434        assert!(xml_xxe_public_external_dtd_finditer("<!DOCTYPE r PUBLIC http://x/d>").is_empty());
435        // the w3.org scheme is excluded from the quote positions
436        assert!(
437            xml_xxe_public_external_dtd_finditer("<!DOCTYPE r PUBLIC \"http://www.w3.org/d.dtd\">")
438                .is_empty()
439        );
440        // two PUBLICs each inside their own run: both match
441        assert_eq!(
442            xml_xxe_public_external_dtd_finditer(
443                "<!DOCTYPE a PUBLIC \"http://x/a.dtd\"><!DOCTYPE b PUBLIC \"http://x/b.dtd\">"
444            )
445            .len(),
446            2
447        );
448    }
449}
450
451#[cfg(test)]
452mod gap_tests {
453    use super::*;
454
455    #[test]
456    fn system_finditer_overlapping_prefix_spans_are_skipped() {
457        // A DOCTYPE carrying an internal subset that itself opens a second
458        // <!ENTITY span: the second prefix starts before last_end and must
459        // take the `prefix_start < last_end` skip.
460        let text = "<!DOCTYPE r [<!ENTITY a SYSTEM \"f\"><!ENTITY b SYSTEM \"g\">]>";
461        assert_eq!(xml_system_finditer(text).len(), 2);
462        // The empty-haystack degenerate keeps the scan honest.
463        assert!(xml_system_finditer("").is_empty());
464        assert!(xml_internal_entity_finditer("").is_empty());
465        assert!(xml_xxe_public_external_dtd_finditer("").is_empty());
466    }
467
468    #[test]
469    fn public_dtd_requires_doctype_well_before_public_in_the_run() {
470        // No DOCTYPE at all: early return through the empty-positions arm.
471        assert!(
472            xml_xxe_public_external_dtd_finditer(r#"PUBLIC "x" "http://evil.example/d" >"#)
473                .is_empty()
474        );
475        // DOCTYPE closer than 10 chars before PUBLIC: the spacing arm
476        // passes here (0 < 11-9), so the candidate matches; the real
477        // spacing rejection needs PUBLIC to overlap the DOCTYPE prefix,
478        // which the grammar cannot express - the arm stays structural.
479        assert_eq!(
480            xml_xxe_public_external_dtd_finditer(
481                r#"<!DOCTYPE r PUBLIC "x" "http://evil.example/d">"#
482            )
483            .len(),
484            1
485        );
486    }
487
488    #[test]
489    fn public_dtd_url_needs_a_quoted_scheme_completion() {
490        // A URL without any quote before the closing '>' yields no quote
491        // position, so the whole scan returns empty.
492        assert!(
493            xml_xxe_public_external_dtd_finditer(
494                r#"<!DOCTYPE r PUBLIC long-gap-here "http://evil.example/d>"#
495            )
496            .is_empty()
497        );
498    }
499
500    #[test]
501    fn public_dtd_scheme_completions_reject_w3_and_unquoted() {
502        // Scheme directly followed by '>' (no class3 boundary): the quoted
503        // completion fails and no quote position is recorded.
504        assert!(
505            xml_xxe_public_external_dtd_finditer(
506                r#"<!DOCTYPE r PUBLIC aaaaaaaaaa "http://evil.example>"#
507            )
508            .is_empty()
509        );
510        // The w3.org allowlist arm: the scheme completes, but the URL host
511        // is w3.org so scheme_completion_end returns None.
512        assert!(
513            xml_xxe_public_external_dtd_finditer(
514                r#"<!DOCTYPE r PUBLIC aaaaaaaaaa "http://www.w3.org/d">"#
515            )
516            .is_empty()
517        );
518    }
519
520    #[test]
521    fn public_dtd_quote_and_gt_geometry_arms() {
522        // The first URL is empty (quote2 lands on the scheme end):
523        // rejected at completion time, but the second URL in the same run
524        // completes and the candidate renders from the shared DOCTYPE.
525        assert_eq!(
526            xml_xxe_public_external_dtd_finditer(
527                r#"<!DOCTYPE r PUBLIC aaaaaaaaaa "http://"d" aaaaaaaaaa "http://evil.example/d">"#
528            )
529            .len(),
530            1
531        );
532        // The first URL's quote is followed by '>' (quoted_url_end's
533        // starts_with('>') rejection); the second URL completes the run's
534        // quote map and the candidate renders from the DOCTYPE.
535        assert_eq!(
536            xml_xxe_public_external_dtd_finditer(
537                r#"<!DOCTYPE r PUBLIC aaaaaaaaaa "http://evil.example/">x aaaaaaaaaa "http://evil.example/d">"#
538            )
539            .len(),
540            1
541        );
542    }
543
544    #[test]
545    fn public_dtd_arms_when_the_quote_or_gt_fall_outside_the_run() {
546        // A '[' inside the URL closes the run before the final '>': the
547        // candidate still renders from the DOCTYPE to the URL's final '>'.
548        assert_eq!(
549            xml_xxe_public_external_dtd_finditer(
550                r#"<!DOCTYPE r PUBLIC aaaaaaaaaa "http://evil.example/d["x">"#
551            )
552            .len(),
553            1
554        );
555    }
556
557    #[test]
558    fn search_between_is_bounded() {
559        let re = compile("x").expect("static");
560        // empty window (start == end) and past-the-end both reject
561        assert!(!search_between(&re, "x", 1, 1));
562        assert!(!search_between(&re, "x", 0, 9));
563    }
564
565    #[test]
566    fn system_finditer_with_a_wide_keyword_window_exercises_search_between() {
567        // A SYSTEM keyword search over a zero-width window (prefix_end+1
568        // past the closing '>'): the window's start >= end rejects before
569        // the regex runs, covering search_between's first arm through the
570        // public matcher instead of a synthetic call.
571        assert!(xml_system_finditer("<<!ENTITY").is_empty());
572        // quote2 == scheme_end through the public path: a URL whose first
573        // quote sits exactly at the scheme end is a zero-length quote; the
574        // second URL completes the candidate.
575        assert_eq!(
576            xml_xxe_public_external_dtd_finditer(
577                r#"<!DOCTYPE r PUBLIC aaaaaaaaaa "http://" aaaaaaaaaa "http://evil.example/d">"#
578            )
579            .len(),
580            1
581        );
582    }
583}