guard_core_engine/patterns/
xml_xxe.rs1use 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#[must_use]
34pub fn xml_system_finditer(haystack: &str) -> Vec<Candidate> {
35 #[cfg(not(coverage))] 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))] 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))] 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#[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))] let scheme = compile(r"https?://")?;
119 #[cfg(coverage)]
120 let scheme = compile(r"https?://").expect("statically valid literal");
121 #[cfg(not(coverage))] 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))] 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#[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))] 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 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("e_positions, public_pos + 7) else {
223 continue;
224 };
225 if quote1 >= run_end {
226 continue;
227 }
228 #[cfg(not(coverage))] let Some(final_gt) = quote_to_final_gt.get("e1) else {
231 continue;
232 };
233 #[cfg(coverage)]
234 let final_gt = quote_to_final_gt
235 .get("e1)
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 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 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 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 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 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 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 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 let unterminated = r#"<!DOCTYPE a PUBLIC "http://evil"#;
371 assert!(xml_xxe_public_external_dtd_finditer(unterminated).is_empty());
372 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 assert_eq!(xml_system_finditer("<!ENTITY x SYSTEM \"file\">").len(), 1);
382 assert!(xml_system_finditer("<!ENTITY x \"file\">").is_empty());
384 assert!(xml_system_finditer("<!ENTITY x SYSTEM \"file\"").is_empty());
386 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 assert_eq!(
397 xml_internal_entity_finditer("<!DOCTYPE r [<!ENTITY x \"v\">]>").len(),
398 1
399 );
400 assert!(xml_internal_entity_finditer("<!DOCTYPE r SYSTEM \"f\">").is_empty());
402 assert!(xml_internal_entity_finditer("<!DOCTYPE r [<!---->]>").is_empty());
404 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 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 assert!(
424 xml_xxe_public_external_dtd_finditer("<!DOCTYPE r PUBLIC \"-//X//DTD//EN\">")
425 .is_empty()
426 );
427 assert!(
429 xml_xxe_public_external_dtd_finditer("<!DOCTYPE r SYSTEM \"http://x/d\">").is_empty()
430 );
431 assert!(xml_xxe_public_external_dtd_finditer("<html/>").is_empty());
433 assert!(xml_xxe_public_external_dtd_finditer("<!DOCTYPE r PUBLIC http://x/d>").is_empty());
435 assert!(
437 xml_xxe_public_external_dtd_finditer("<!DOCTYPE r PUBLIC \"http://www.w3.org/d.dtd\">")
438 .is_empty()
439 );
440 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 let text = "<!DOCTYPE r [<!ENTITY a SYSTEM \"f\"><!ENTITY b SYSTEM \"g\">]>";
461 assert_eq!(xml_system_finditer(text).len(), 2);
462 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 assert!(
472 xml_xxe_public_external_dtd_finditer(r#"PUBLIC "x" "http://evil.example/d" >"#)
473 .is_empty()
474 );
475 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 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 assert!(
505 xml_xxe_public_external_dtd_finditer(
506 r#"<!DOCTYPE r PUBLIC aaaaaaaaaa "http://evil.example>"#
507 )
508 .is_empty()
509 );
510 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 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 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 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 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 assert!(xml_system_finditer("<<!ENTITY").is_empty());
572 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}