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 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#[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#[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 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("e_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("e1) 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}