merman_render/svg/pipeline/builtin/
attr_sanitize.rs1use crate::Result;
2use std::borrow::Cow;
3
4use super::css_sanitize::strip_css_deg_units;
5use super::util::{SvgTagScanner, next_svg_quoted_attr};
6use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
7
8#[derive(Debug, Clone, Copy, Default)]
9pub struct SanitizeSvgAttributesPostprocessor;
10
11impl SvgPostprocessor for SanitizeSvgAttributesPostprocessor {
12 fn name(&self) -> &'static str {
13 "sanitize-svg-attributes"
14 }
15
16 fn process<'a>(
17 &self,
18 svg: Cow<'a, str>,
19 _ctx: &SvgPostprocessContext<'_>,
20 ) -> Result<Cow<'a, str>> {
21 Ok(Cow::Owned(sanitize_element_attributes(&svg)))
22 }
23}
24
25pub(crate) fn sanitize_element_attributes(svg: &str) -> String {
26 let mut out = String::with_capacity(svg.len());
27 let mut scanner = SvgTagScanner::new(svg);
28 let mut copied_until = 0;
29
30 while let Some(tag) = scanner.next() {
31 out.push_str(&svg[copied_until..tag.start()]);
32
33 let raw_tag = tag.raw();
34 if let Some(active_name) = active_svg_element_name(raw_tag) {
35 copied_until = if tag.is_self_closing() {
36 scanner.cursor()
37 } else {
38 find_close_tag_end(svg, scanner.cursor(), active_name).unwrap_or(scanner.cursor())
39 };
40 scanner.skip_to(copied_until);
41 continue;
42 }
43
44 if is_bad_rect_tag(raw_tag) {
45 copied_until = if tag.is_self_closing() {
46 scanner.cursor()
47 } else {
48 svg[scanner.cursor()..]
49 .find("</rect>")
50 .map(|rel_close| scanner.cursor() + rel_close + "</rect>".len())
51 .unwrap_or(scanner.cursor())
52 };
53 scanner.skip_to(copied_until);
54 continue;
55 }
56 out.push_str(&sanitize_tag_attributes(raw_tag));
57 copied_until = scanner.cursor();
58 }
59
60 out.push_str(&svg[copied_until..]);
61 out
62}
63
64fn sanitize_tag_attributes(tag: &str) -> Cow<'_, str> {
65 if tag.starts_with("</")
66 || tag.starts_with("<!--")
67 || tag.starts_with("<!")
68 || tag.starts_with("<?")
69 {
70 return Cow::Borrowed(tag);
71 }
72
73 let mut changed = false;
74 let mut out = String::new();
75 let mut copied_until = 0usize;
76 let mut cursor = 0usize;
77
78 while let Some(attr) = next_svg_quoted_attr(tag, cursor) {
79 let name = &tag[attr.name_start..attr.name_end];
80 let value = &tag[attr.value_start..attr.value_end];
81
82 let replacement = sanitized_attr_replacement(name, value);
83 if let AttrReplacement::Unchanged = replacement {
84 cursor = attr.full_end;
85 continue;
86 }
87
88 if !changed {
89 out = String::with_capacity(tag.len());
90 changed = true;
91 }
92 out.push_str(&tag[copied_until..attr.full_start]);
93 match replacement {
94 AttrReplacement::Unchanged => {}
95 AttrReplacement::Drop => {}
96 AttrReplacement::Replace(replacement) => out.push_str(&replacement),
97 }
98 copied_until = attr.full_end;
99 cursor = attr.full_end;
100 }
101
102 if changed {
103 out.push_str(&tag[copied_until..]);
104 Cow::Owned(out)
105 } else {
106 Cow::Borrowed(tag)
107 }
108}
109
110enum AttrReplacement {
111 Unchanged,
112 Drop,
113 Replace(String),
114}
115
116fn sanitized_attr_replacement(name: &str, value: &str) -> AttrReplacement {
117 if should_drop_attribute(name, value) {
118 return AttrReplacement::Drop;
119 }
120
121 if let Some(value) = normalize_px_attribute(name, value) {
122 return AttrReplacement::Replace(format!(r#" {name}="{value}""#));
123 }
124
125 if name.eq_ignore_ascii_case("style") {
126 let sanitized = sanitize_style_attribute(value);
127 if sanitized.trim().is_empty() {
128 return AttrReplacement::Drop;
129 }
130 if sanitized != value {
131 return AttrReplacement::Replace(format!(r#" style="{sanitized}""#));
132 }
133 }
134
135 AttrReplacement::Unchanged
136}
137
138fn should_drop_attribute(name: &str, value: &str) -> bool {
139 if name.eq_ignore_ascii_case("style") {
140 return false;
141 }
142
143 if is_event_handler_attribute(name) || is_unsafe_url_attribute(name, value) {
144 return true;
145 }
146
147 let normalized = name.to_ascii_lowercase();
148 if is_url_function_attribute(&normalized) && css_value_contains_unsafe_url_function(value) {
149 return true;
150 }
151
152 let guarded = matches!(
153 normalized.as_str(),
154 "fill"
155 | "stroke"
156 | "width"
157 | "height"
158 | "x"
159 | "y"
160 | "x1"
161 | "x2"
162 | "y1"
163 | "y2"
164 | "r"
165 | "cx"
166 | "cy"
167 | "rx"
168 | "ry"
169 | "stroke-width"
170 | "transform"
171 | "d"
172 | "points"
173 );
174
175 guarded && is_invalid_svg_value(value)
176}
177
178fn is_event_handler_attribute(name: &str) -> bool {
179 let name = local_name(name.trim());
180 name.len() > 2
181 && name
182 .get(..2)
183 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("on"))
184 && name.as_bytes()[2].is_ascii_alphabetic()
185}
186
187fn is_unsafe_url_attribute(name: &str, value: &str) -> bool {
188 let normalized = name.trim().to_ascii_lowercase();
189 if !matches!(normalized.as_str(), "href" | "xlink:href" | "src") {
190 return false;
191 }
192
193 is_unsafe_url_value(value)
194}
195
196fn is_url_function_attribute(name: &str) -> bool {
197 matches!(
198 name,
199 "fill"
200 | "stroke"
201 | "filter"
202 | "clip-path"
203 | "mask"
204 | "marker-start"
205 | "marker-mid"
206 | "marker-end"
207 )
208}
209
210fn is_unsafe_url_value(value: &str) -> bool {
211 let value = normalize_url_attr_for_scheme_check(value);
212 if value.is_empty() || value.starts_with('#') {
213 return false;
214 }
215
216 if value.starts_with("data:") {
217 return !is_safe_data_image_url(&value);
218 }
219
220 let Some(colon) = value.find(':') else {
221 return false;
222 };
223 let scheme = &value[..colon];
224 !matches!(scheme, "http" | "https" | "mailto")
225}
226
227fn css_value_contains_unsafe_url_function(value: &str) -> bool {
228 let lower = value.to_ascii_lowercase();
229 let mut cursor = 0usize;
230 while let Some(rel_start) = lower[cursor..].find("url(") {
231 let arg_start = cursor + rel_start + "url(".len();
232 let Some(rel_end) = lower[arg_start..].find(')') else {
233 return true;
234 };
235 let arg_end = arg_start + rel_end;
236 if is_unsafe_url_value(trim_css_url_argument(&value[arg_start..arg_end])) {
237 return true;
238 }
239 cursor = arg_end + 1;
240 }
241 false
242}
243
244fn trim_css_url_argument(value: &str) -> &str {
245 let value = value.trim();
246 if value.len() >= 2 {
247 let bytes = value.as_bytes();
248 if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
249 || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
250 {
251 return &value[1..value.len() - 1];
252 }
253 }
254 value
255}
256
257fn normalize_url_attr_for_scheme_check(value: &str) -> String {
258 let decoded = merman_core::entities::decode_html_entities_to_unicode(value);
259 let mut out = String::with_capacity(decoded.len());
260 for ch in decoded.trim().chars() {
261 if !ch.is_whitespace() && !ch.is_control() {
262 out.extend(ch.to_lowercase());
263 }
264 }
265 out
266}
267
268fn is_safe_data_image_url(value: &str) -> bool {
269 let Some(media_type) = value.strip_prefix("data:") else {
270 return false;
271 };
272 let media_type = media_type
273 .split_once(',')
274 .map_or(media_type, |(head, _)| head);
275 matches!(
276 media_type.split(';').next().unwrap_or_default(),
277 "image/png" | "image/jpeg" | "image/jpg" | "image/gif" | "image/webp"
278 )
279}
280
281fn normalize_px_attribute(name: &str, value: &str) -> Option<String> {
282 let normalized = name.to_ascii_lowercase();
283 let guarded = matches!(
284 normalized.as_str(),
285 "width"
286 | "height"
287 | "x"
288 | "y"
289 | "x1"
290 | "x2"
291 | "y1"
292 | "y2"
293 | "r"
294 | "cx"
295 | "cy"
296 | "rx"
297 | "ry"
298 | "stroke-width"
299 );
300 if !guarded {
301 return None;
302 }
303
304 let trimmed = value.trim();
305 let number = trimmed.strip_suffix("px")?.trim();
306 if number.parse::<f64>().is_ok_and(f64::is_finite) {
307 Some(number.to_string())
308 } else {
309 None
310 }
311}
312
313fn is_start_or_empty_tag(tag: &str, expected: &str) -> bool {
314 let tag = tag.trim_start();
315 if !tag.starts_with('<') || tag.starts_with("</") || tag.starts_with("<!--") {
316 return false;
317 }
318
319 let name = tag[1..]
320 .chars()
321 .take_while(|ch| !ch.is_whitespace() && *ch != '/' && *ch != '>')
322 .collect::<String>();
323 name.eq_ignore_ascii_case(expected)
324}
325
326fn start_tag_name(tag: &str) -> Option<&str> {
327 let tag = tag.trim_start();
328 if !tag.starts_with('<')
329 || tag.starts_with("</")
330 || tag.starts_with("<!--")
331 || tag.starts_with("<!")
332 || tag.starts_with("<?")
333 {
334 return None;
335 }
336
337 let start = 1;
338 let end = start
339 + tag[start..]
340 .find(|ch: char| ch.is_whitespace() || ch == '/' || ch == '>')
341 .unwrap_or(tag.len() - start);
342 (start < end).then_some(&tag[start..end])
343}
344
345fn active_svg_element_name(tag: &str) -> Option<&str> {
346 let name = start_tag_name(tag)?;
347 matches_active_svg_element(name).then_some(name)
348}
349
350fn matches_active_svg_element(name: &str) -> bool {
351 matches!(
352 local_name(name).to_ascii_lowercase().as_str(),
353 "script" | "iframe" | "object" | "embed" | "foreignobject"
354 )
355}
356
357fn local_name(name: &str) -> &str {
358 name.rsplit_once(':')
359 .map_or(name, |(_, local_name)| local_name)
360}
361
362fn find_close_tag_end(svg: &str, from: usize, name: &str) -> Option<usize> {
363 let mut scanner = SvgTagScanner::new(svg);
364 scanner.skip_to(from);
365 while let Some(tag) = scanner.next() {
366 if close_tag_matches(tag.raw(), name) {
367 return Some(scanner.cursor());
368 }
369 }
370 None
371}
372
373fn close_tag_matches(tag: &str, expected: &str) -> bool {
374 let tag = tag.trim_start();
375 if !tag.starts_with("</") {
376 return false;
377 }
378 let name_start = 2;
379 let name_end = name_start
380 + tag[name_start..]
381 .find(|ch: char| ch.is_whitespace() || ch == '>')
382 .unwrap_or(tag.len() - name_start);
383 if name_start >= name_end {
384 return false;
385 }
386 let after_name = tag[name_end..].trim();
387 after_name == ">" && tag[name_start..name_end].eq_ignore_ascii_case(expected)
388}
389
390fn attr_value(tag: &str, name: &str) -> Option<String> {
391 let mut cursor = 0usize;
392 while let Some(attr) = next_svg_quoted_attr(tag, cursor) {
393 if tag[attr.name_start..attr.name_end].eq_ignore_ascii_case(name) {
394 return Some(tag[attr.value_start..attr.value_end].to_string());
395 }
396 cursor = attr.full_end;
397 }
398 None
399}
400
401fn is_missing_or_invalid_rect_dimension(value: Option<&str>) -> bool {
402 let Some(value) = value.map(str::trim) else {
403 return true;
404 };
405 if value.is_empty() {
406 return true;
407 }
408 if let Ok(n) = value.parse::<f64>() {
409 return !n.is_finite() || n <= 0.0;
410 }
411 false
412}
413
414fn is_bad_rect_tag(tag: &str) -> bool {
415 if !is_start_or_empty_tag(tag, "rect") {
416 return false;
417 }
418
419 let width = attr_value(tag, "width");
420 let height = attr_value(tag, "height");
421 is_missing_or_invalid_rect_dimension(width.as_deref())
422 || is_missing_or_invalid_rect_dimension(height.as_deref())
423}
424
425fn sanitize_style_attribute(value: &str) -> String {
426 let mut out = Vec::new();
427
428 for decl in value.split(';') {
429 let trimmed = decl.trim();
430 if trimmed.is_empty() {
431 continue;
432 }
433
434 let Some((property, raw_value)) = trimmed.split_once(':') else {
435 if is_invalid_svg_value(trimmed) {
436 continue;
437 }
438 out.push(strip_css_deg_units(trimmed));
439 continue;
440 };
441
442 let property = property.trim();
443 let value = raw_value.trim();
444 if value.is_empty()
445 || is_invalid_svg_value(value)
446 || css_value_contains_unsafe_url_function(value)
447 {
448 continue;
449 }
450 if property
451 .trim()
452 .to_ascii_lowercase()
453 .starts_with("animation")
454 {
455 continue;
456 }
457
458 out.push(format!("{property}:{}", strip_css_deg_units(value)));
459 }
460
461 out.join(";")
462}
463
464fn is_invalid_svg_value(value: &str) -> bool {
465 let value = value.trim();
466 if value.is_empty() {
467 return true;
468 }
469
470 let lower = value.to_ascii_lowercase();
471 lower.contains("nan") || lower.contains("undefined") || lower.contains("infinity")
472}
473
474#[cfg(test)]
475mod tests {
476 use super::sanitize_element_attributes;
477
478 #[test]
479 fn sanitize_style_attribute_drops_invalid_bare_declarations() {
480 let svg = r#"<svg><path style="undefined; stroke: #333; undefined"/></svg>"#;
481 let out = sanitize_element_attributes(svg);
482
483 assert!(!out.contains("undefined"), "got: {out}");
484 assert!(out.contains(r#"style="stroke:#333""#), "got: {out}");
485 }
486
487 #[test]
488 fn sanitize_element_attributes_drops_rects_without_positive_dimensions() {
489 let svg = r#"<svg><rect/><rect width="0" height="10"/><rect width="12" height="8"/><g><rect width="NaN" height="10"><title>bad</title></rect></g></svg>"#;
490 let out = sanitize_element_attributes(svg);
491
492 assert!(!out.contains("<rect/>"), "got: {out}");
493 assert!(!out.contains(r#"width="0""#), "got: {out}");
494 assert!(!out.contains("NaN"), "got: {out}");
495 assert!(!out.contains("<title>bad</title>"), "got: {out}");
496 assert!(
497 out.contains(r#"<rect width="12" height="8"/>"#),
498 "got: {out}"
499 );
500 }
501
502 #[test]
503 fn sanitize_element_attributes_scans_double_quoted_attrs_without_regex() {
504 let svg = r#"<svg><path data-keep = "ok" x = "10px" stroke="" style="transform: rotate(45deg); animation: dash 1s; stroke: #333;"/></svg>"#;
505 let out = sanitize_element_attributes(svg);
506
507 assert!(out.contains(r#"data-keep = "ok""#), "got: {out}");
508 assert!(out.contains(r#" x="10""#), "got: {out}");
509 assert!(!out.contains(r#"stroke="""#), "got: {out}");
510 assert!(
511 out.contains(r#"style="transform:rotate(45);stroke:#333""#),
512 "got: {out}"
513 );
514 assert!(!out.contains("animation"), "got: {out}");
515 }
516
517 #[test]
518 fn sanitize_element_attributes_scans_single_quoted_attrs() {
519 let svg = r#"<svg><path x = '10px' style='animation: dash 1s; stroke: #333;'/></svg>"#;
520 let out = sanitize_element_attributes(svg);
521
522 assert!(out.contains(r#" x="10""#), "got: {out}");
523 assert!(out.contains(r#"style="stroke:#333""#), "got: {out}");
524 assert!(!out.contains("animation"), "got: {out}");
525 }
526
527 #[test]
528 fn sanitize_element_attributes_uses_scanned_attrs_for_bad_rect_detection() {
529 let svg = r#"<svg><rect WIDTH = "12" HEIGHT = "8"/><rect width = "NaN" height = "8"><title>bad</title></rect></svg>"#;
530 let out = sanitize_element_attributes(svg);
531
532 assert!(
533 out.contains(r#"<rect WIDTH = "12" HEIGHT = "8"/>"#),
534 "got: {out}"
535 );
536 assert!(!out.contains("NaN"), "got: {out}");
537 assert!(!out.contains("<title>bad</title>"), "got: {out}");
538 }
539
540 #[test]
541 fn sanitize_element_attributes_strips_active_svg_elements() {
542 let svg = r#"<svg><script>alert(1)</script><svg:script>alert(2)</svg:script><SCRIPT/><iframe src="https://example.com"></iframe><object data="x"></object><rect width="12" height="8"/></svg>"#;
543 let out = sanitize_element_attributes(svg);
544
545 assert!(!out.to_ascii_lowercase().contains("<script"), "got: {out}");
546 assert!(
547 !out.to_ascii_lowercase().contains("<svg:script"),
548 "got: {out}"
549 );
550 assert!(!out.to_ascii_lowercase().contains("<iframe"), "got: {out}");
551 assert!(!out.to_ascii_lowercase().contains("<object"), "got: {out}");
552 assert!(
553 out.contains(r#"<rect width="12" height="8"/>"#),
554 "got: {out}"
555 );
556 }
557
558 #[test]
559 fn sanitize_element_attributes_strips_event_and_unsafe_url_attrs() {
560 let svg = r##"<svg><a href="#local"><use href="#shape" xlink:href="#shape"/></a><a href="javascript:alert(1)" onclick="alert(1)" svg:onload="alert(1)"><text>bad</text></a><image href="data:image/png;base64,AAAA"/><image href="data:text/html;base64,AAAA"/><image href="file:///etc/passwd"/><a href="java:script:alert(1)"/></svg>"##;
561 let out = sanitize_element_attributes(svg);
562 let lower = out.to_ascii_lowercase();
563
564 assert!(out.contains(r##"href="#local""##), "got: {out}");
565 assert!(out.contains(r##"href="#shape""##), "got: {out}");
566 assert!(out.contains(r##"xlink:href="#shape""##), "got: {out}");
567 assert!(
568 out.contains(r#"href="data:image/png;base64,AAAA""#),
569 "got: {out}"
570 );
571 assert!(!lower.contains("onclick"), "got: {out}");
572 assert!(!lower.contains("svg:onload"), "got: {out}");
573 assert!(!lower.contains("javascript"), "got: {out}");
574 assert!(!lower.contains("data:text/html"), "got: {out}");
575 assert!(!lower.contains("file:///"), "got: {out}");
576 }
577
578 #[test]
579 fn sanitize_element_attributes_strips_unsafe_css_url_functions() {
580 let svg = r##"<svg><path fill="url(#paint)" stroke="url(javascript:alert(1))" style="clip-path: url(#clip); filter: url('javascript:alert(1)'); stroke: #333"/></svg>"##;
581 let out = sanitize_element_attributes(svg);
582 let lower = out.to_ascii_lowercase();
583
584 assert!(out.contains(r##"fill="url(#paint)""##), "got: {out}");
585 assert!(out.contains("clip-path:url(#clip)"), "got: {out}");
586 assert!(out.contains("stroke:#333"), "got: {out}");
587 assert!(!lower.contains("javascript"), "got: {out}");
588 assert!(!lower.contains(r#"stroke="url("#), "got: {out}");
589 assert!(!lower.contains("filter:"), "got: {out}");
590 }
591}