use super::pyregex::{Candidate, PyRegex};
const SYSTEM_PREFIX: &str = r"<!(?:ENTITY|DOCTYPE)";
const PUBLIC_EXTERNAL_DTD_RE: &str =
r#"<!DOCTYPE[^>\[]+PUBLIC[^>\[]+["']https?://(?!(?:www\.)?w3\.org/)[^"'>]+["'][^>\[]*>"#;
fn compile(source: &str) -> Option<PyRegex> {
PyRegex::compile(source, true).ok()
}
fn find_positions(source: &str, haystack: &str) -> Vec<usize> {
compile(source)
.map(|re| re.re().find_iter(haystack).map(|m| m.start()).collect())
.unwrap_or_default()
}
fn first_at_or_after(sorted: &[usize], floor: usize) -> Option<usize> {
sorted.iter().copied().find(|p| *p >= floor)
}
fn search_between(re: &PyRegex, haystack: &str, start: usize, end: usize) -> bool {
if start >= end || end > haystack.len() {
return false;
}
re.re().is_match(&haystack[start..end])
}
#[must_use]
pub fn xml_system_finditer(haystack: &str) -> Vec<Candidate> {
let Some(gt) = compile(">") else {
return Vec::new();
};
let Some(prefix) = compile(SYSTEM_PREFIX) else {
return Vec::new();
};
let Some(keyword) = compile("SYSTEM") else {
return Vec::new();
};
let ends: Vec<usize> = gt.re().find_iter(haystack).map(|m| m.start()).collect();
let mut matches = Vec::new();
let mut last_end = 0usize;
for prefix_match in prefix.re().find_iter(haystack) {
let prefix_start = prefix_match.start();
let prefix_end = prefix_match.end();
if prefix_start < last_end {
continue;
}
let Some(end) = first_at_or_after(&ends, prefix_end) else {
return matches;
};
last_end = end + 1;
if search_between(&keyword, haystack, prefix_end + 1, end) {
matches.push(Candidate::new(prefix_start, last_end));
}
}
matches
}
#[must_use]
pub fn xml_internal_entity_finditer(haystack: &str) -> Vec<Candidate> {
let boundaries = find_positions(r"[>\[]", haystack);
let entities = find_positions("<!ENTITY", haystack);
let doctypes = find_positions("<!DOCTYPE", haystack);
let mut matches = Vec::new();
let mut last_end = 0usize;
for prefix_start in doctypes {
let prefix_end = prefix_start + "<!DOCTYPE".len();
if prefix_start < last_end {
continue;
}
let Some(boundary) = first_at_or_after(&boundaries, prefix_end) else {
return matches;
};
last_end = boundary + 1;
if !haystack[boundary..].starts_with('[') {
continue;
}
let Some(entity) = first_at_or_after(&entities, boundary + 1) else {
return matches;
};
last_end = entity + "<!ENTITY".len();
matches.push(Candidate::new(prefix_start, last_end));
}
matches
}
fn scheme_completion_end(
haystack: &str,
scheme_start: usize,
class12_boundaries: &[usize],
class3_boundaries: &[usize],
) -> Option<usize> {
if scheme_start == 0
|| !haystack[..scheme_start]
.chars()
.next_back()
.is_some_and(|c| c == '"' || c == '\'')
{
return None;
}
let scheme = compile(r"https?://")?;
let m = scheme.re().find_at(haystack, scheme_start)?;
if m.start() != scheme_start {
return None;
}
let scheme_end = m.end();
let w3 = compile(r"(?:www\.)?w3\.org/")?;
if w3
.re()
.find_at(haystack, scheme_end)
.is_some_and(|m| m.start() == scheme_end)
{
return None;
}
quoted_url_end(haystack, scheme_end, class12_boundaries, class3_boundaries)
}
fn quoted_url_end(
haystack: &str,
scheme_end: usize,
class12_boundaries: &[usize],
class3_boundaries: &[usize],
) -> Option<usize> {
let quote2 = first_at_or_after(class3_boundaries, scheme_end)?;
if quote2 == scheme_end || haystack[quote2..].starts_with('>') {
return None;
}
let final_boundary = first_at_or_after(class12_boundaries, quote2 + 1)?;
haystack[final_boundary..]
.starts_with('>')
.then_some(final_boundary)
}
#[must_use]
pub fn xml_xxe_public_external_dtd_finditer(haystack: &str) -> Vec<Candidate> {
let doctype_positions = find_positions("<!DOCTYPE", haystack);
let public_positions = find_positions("PUBLIC", haystack);
if doctype_positions.is_empty() || public_positions.is_empty() {
return Vec::new();
}
let class12_boundaries = find_positions(r"[>\[]", haystack);
let class3_boundaries = find_positions(r#"["'>]"#, haystack);
let Some(scheme) = compile(r"https?://") else {
return Vec::new();
};
let mut quote_positions: Vec<usize> = Vec::new();
let mut quote_to_final_gt: std::collections::HashMap<usize, usize> =
std::collections::HashMap::new();
for m in scheme.re().find_iter(haystack) {
if let Some(final_gt) =
scheme_completion_end(haystack, m.start(), &class12_boundaries, &class3_boundaries)
{
let quote_pos = m.start() - 1;
quote_positions.push(quote_pos);
quote_to_final_gt.insert(quote_pos, final_gt);
}
}
if quote_positions.is_empty() {
return Vec::new();
}
let mut matches = Vec::new();
let mut last_end = 0usize;
for public_pos in public_positions {
if public_pos < last_end {
continue;
}
let run_start = class12_boundaries
.iter()
.copied()
.rfind(|p| *p <= public_pos)
.map_or(0, |p| p + 1);
let run_end = class12_boundaries
.iter()
.copied()
.find(|p| *p > public_pos)
.unwrap_or(haystack.len());
let Some(doctype_before) = first_at_or_after(&doctype_positions, run_start) else {
continue;
};
if doctype_before >= public_pos.saturating_sub(9) {
continue;
}
let Some(quote1) = first_at_or_after("e_positions, public_pos + 7) else {
continue;
};
if quote1 >= run_end {
continue;
}
let Some(final_gt) = quote_to_final_gt.get("e1) else {
continue;
};
let candidate = Candidate::new(doctype_before, final_gt + 1);
matches.push(candidate);
last_end = candidate.end;
}
matches
}
#[must_use]
pub const fn public_external_dtd_source() -> &'static str {
PUBLIC_EXTERNAL_DTD_RE
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_entity_requires_system_keyword() {
let hits = xml_system_finditer(r#"<!ENTITY xxe SYSTEM "file:///etc/passwd">"#);
assert_eq!(hits.len(), 1);
assert!(xml_system_finditer(r#"<!ENTITY xxe "plain">"#).is_empty());
}
#[test]
fn internal_entity_requires_bracket_section() {
let text = "<!DOCTYPE foo [<!ENTITY xxe \"bar\">]>";
let hits = xml_internal_entity_finditer(text);
assert_eq!(hits.len(), 1);
assert!(xml_internal_entity_finditer("<!DOCTYPE foo SYSTEM \"x\">").is_empty());
}
#[test]
fn public_external_dtd_rejects_w3_org() {
let good = r#"<!DOCTYPE foo PUBLIC "-//X//DTD//EN" "http://evil.example/dtd">"#;
assert_eq!(xml_xxe_public_external_dtd_finditer(good).len(), 1);
let w3 = r#"<!DOCTYPE foo PUBLIC "-//W3C//DTD//EN" "http://www.w3.org/dtd">"#;
assert!(xml_xxe_public_external_dtd_finditer(w3).is_empty());
}
}