1use crate::Result;
2use cssparser::{Delimiter, Parser};
3use merman_core::svg_security::{MermaidSvgUriRepresentation, admit_mermaid_svg_uri_attribute};
4use std::borrow::Cow;
5use std::convert::Infallible;
6
7use super::css_sanitize::sanitize_css_value_with_checkpoints;
8use super::presentation_fallback::is_mermaid_missing_amount_hsl;
9use super::util::{
10 SvgTagScanner, checkpoint_loop, escape_xml_attr, find_with_checkpoints, next_svg_quoted_attr,
11 next_svg_quoted_attr_with_checkpoints, start_tag_name,
12};
13use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
14
15#[derive(Debug, Clone, Copy, Default)]
16pub struct SanitizeSvgAttributesPostprocessor;
17
18impl SvgPostprocessor for SanitizeSvgAttributesPostprocessor {
19 fn name(&self) -> &'static str {
20 "sanitize-svg-attributes"
21 }
22
23 fn process<'a>(
24 &self,
25 svg: Cow<'a, str>,
26 ctx: &SvgPostprocessContext<'_>,
27 ) -> Result<Cow<'a, str>> {
28 sanitize_element_attributes_cow_with_checkpoints(svg, &mut || ctx.checkpoint())
29 }
30}
31
32#[cfg(test)]
33fn sanitize_element_attributes(svg: &str) -> String {
34 infallible(sanitize_element_attributes_cow_with_checkpoints(
35 Cow::Borrowed(svg),
36 &mut || Ok::<(), Infallible>(()),
37 ))
38 .into_owned()
39}
40
41fn infallible<T>(result: std::result::Result<T, Infallible>) -> T {
42 match result {
43 Ok(value) => value,
44 Err(error) => match error {},
45 }
46}
47
48pub(crate) fn sanitize_element_attributes_cow_with_checkpoints<'a, E>(
49 svg: Cow<'a, str>,
50 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
51) -> std::result::Result<Cow<'a, str>, E> {
52 let source = svg.as_ref();
53 let mut out = None::<String>;
54 let mut scanner = SvgTagScanner::new(source);
55 let mut copied_until = 0;
56 let mut tag_index = 0usize;
57
58 while let Some(tag) = scanner.next_with_checkpoints(checkpoint)? {
59 checkpoint_loop(tag_index, checkpoint)?;
60 tag_index = tag_index.saturating_add(1);
61 let raw_tag = tag.raw();
62 if let Some(active_name) = active_svg_element_name(raw_tag) {
63 let output = out.get_or_insert_with(|| String::with_capacity(source.len()));
64 output.push_str(&source[copied_until..tag.start()]);
65 copied_until = if tag.is_self_closing() {
66 scanner.cursor()
67 } else {
68 find_close_tag_end(source, scanner.cursor(), active_name, checkpoint)?
69 .unwrap_or(scanner.cursor())
70 };
71 scanner.skip_to(copied_until);
72 continue;
73 }
74
75 if is_bad_rect_tag(raw_tag) {
76 let output = out.get_or_insert_with(|| String::with_capacity(source.len()));
77 output.push_str(&source[copied_until..tag.start()]);
78 copied_until = if tag.is_self_closing() {
79 scanner.cursor()
80 } else {
81 find_with_checkpoints(&source[scanner.cursor()..], "</rect>", checkpoint)?
82 .map(|rel_close| scanner.cursor() + rel_close + "</rect>".len())
83 .unwrap_or(scanner.cursor())
84 };
85 scanner.skip_to(copied_until);
86 continue;
87 }
88
89 match sanitize_tag_attributes(raw_tag, checkpoint)? {
90 Cow::Borrowed(_) => {
91 if let Some(output) = out.as_mut() {
92 output.push_str(&source[copied_until..scanner.cursor()]);
93 copied_until = scanner.cursor();
94 }
95 }
96 Cow::Owned(sanitized) => {
97 let output = out.get_or_insert_with(|| String::with_capacity(source.len()));
98 output.push_str(&source[copied_until..tag.start()]);
99 output.push_str(&sanitized);
100 copied_until = scanner.cursor();
101 }
102 }
103 }
104
105 let Some(mut out) = out else {
106 checkpoint()?;
107 return Ok(svg);
108 };
109 out.push_str(&source[copied_until..]);
110 checkpoint()?;
111 Ok(Cow::Owned(out))
112}
113
114fn sanitize_tag_attributes<'a, E>(
115 tag: &'a str,
116 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
117) -> std::result::Result<Cow<'a, str>, E> {
118 if tag.starts_with("</")
119 || tag.starts_with("<!--")
120 || tag.starts_with("<!")
121 || tag.starts_with("<?")
122 {
123 return Ok(Cow::Borrowed(tag));
124 }
125
126 let element_name = start_tag_name(tag).map(local_name).unwrap_or_default();
127 let mut changed = false;
128 let mut out = String::new();
129 let mut copied_until = 0usize;
130 let mut cursor = 0usize;
131 let mut attr_index = 0usize;
132
133 while let Some(attr) = next_svg_quoted_attr_with_checkpoints(tag, cursor, checkpoint)? {
134 checkpoint_loop(attr_index, checkpoint)?;
135 attr_index = attr_index.saturating_add(1);
136 let name = &tag[attr.name_start..attr.name_end];
137 let value = &tag[attr.value_start..attr.value_end];
138
139 let replacement = sanitized_attr_replacement(element_name, name, value, checkpoint)?;
140 if let AttrReplacement::Unchanged = replacement {
141 cursor = attr.full_end;
142 continue;
143 }
144
145 if !changed {
146 out = String::with_capacity(tag.len());
147 changed = true;
148 }
149 out.push_str(&tag[copied_until..attr.full_start]);
150 match replacement {
151 AttrReplacement::Unchanged => {}
152 AttrReplacement::Drop => {}
153 AttrReplacement::Replace(replacement) => out.push_str(&replacement),
154 }
155 copied_until = attr.full_end;
156 cursor = attr.full_end;
157 }
158
159 if changed {
160 out.push_str(&tag[copied_until..]);
161 Ok(Cow::Owned(out))
162 } else {
163 Ok(Cow::Borrowed(tag))
164 }
165}
166
167enum AttrReplacement {
168 Unchanged,
169 Drop,
170 Replace(String),
171}
172
173fn sanitized_attr_replacement<E>(
174 element_name: &str,
175 name: &str,
176 value: &str,
177 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
178) -> std::result::Result<AttrReplacement, E> {
179 checkpoint()?;
180 if is_namespace_declaration(name) {
181 return Ok(AttrReplacement::Unchanged);
182 }
183
184 if is_anchor_navigation_attribute(element_name, name) {
185 let Some(normalized_value) =
186 admit_mermaid_svg_uri_attribute(value, MermaidSvgUriRepresentation::SerializedSvg)
187 else {
188 return Ok(AttrReplacement::Drop);
189 };
190 let normalized_value = escape_xml_attr(&normalized_value);
191 if normalized_value != value {
192 return Ok(AttrReplacement::Replace(format!(
193 r#" {name}="{normalized_value}""#
194 )));
195 }
196 return Ok(AttrReplacement::Unchanged);
197 }
198
199 if should_drop_attribute_with_checkpoints(element_name, name, value, checkpoint)? {
200 return Ok(AttrReplacement::Drop);
201 }
202
203 if let Some(value) = normalize_px_attribute(name, value) {
204 return Ok(AttrReplacement::Replace(format!(r#" {name}="{value}""#)));
205 }
206
207 if local_name(name).eq_ignore_ascii_case("style") {
208 let sanitized = sanitize_style_attribute(value, checkpoint)?;
209 if sanitized.trim().is_empty() {
210 return Ok(AttrReplacement::Drop);
211 }
212 if sanitized != value {
213 return Ok(AttrReplacement::Replace(format!(
214 r#" {name}="{sanitized}""#
215 )));
216 }
217 }
218
219 checkpoint()?;
220 Ok(AttrReplacement::Unchanged)
221}
222
223fn should_drop_attribute(element_name: &str, name: &str, value: &str) -> bool {
224 infallible(should_drop_attribute_with_checkpoints(
225 element_name,
226 name,
227 value,
228 &mut || Ok::<(), Infallible>(()),
229 ))
230}
231
232fn should_drop_attribute_with_checkpoints<E>(
233 element_name: &str,
234 name: &str,
235 value: &str,
236 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
237) -> std::result::Result<bool, E> {
238 if is_namespace_declaration(name) {
239 return Ok(false);
240 }
241
242 let semantic_name = local_name(name.trim());
243 if semantic_name.eq_ignore_ascii_case("style") {
244 return Ok(false);
245 }
246
247 if is_event_handler_attribute(name)
248 || is_unsafe_url_attribute(element_name, name, value)
249 || is_base_url_attribute(name)
250 {
251 return Ok(true);
252 }
253
254 let normalized = semantic_name.to_ascii_lowercase();
255 if is_url_function_attribute(&normalized) && css_value_violates_url_safety(value, checkpoint)? {
256 return Ok(true);
257 }
258
259 if value.trim().is_empty() {
260 return Ok(matches!(
261 normalized.as_str(),
262 "fill"
263 | "stroke"
264 | "width"
265 | "height"
266 | "x"
267 | "y"
268 | "x1"
269 | "x2"
270 | "y1"
271 | "y2"
272 | "r"
273 | "cx"
274 | "cy"
275 | "rx"
276 | "ry"
277 | "stroke-width"
278 | "transform"
279 | "d"
280 | "points"
281 ));
282 }
283
284 let drop = match normalized.as_str() {
285 "fill" | "stroke" => is_mermaid_missing_amount_hsl(value),
286 "width" | "height" | "x" | "y" | "x1" | "x2" | "y1" | "y2" | "r" | "cx" | "cy" | "rx"
287 | "ry" | "stroke-width" => is_provably_invalid_scalar(value),
288 "transform" => is_invalid_svg_transform(value),
289 "d" | "points" => contains_non_finite_numeric_token(value),
290 _ => false,
291 };
292 checkpoint()?;
293 Ok(drop)
294}
295
296pub(in crate::svg::pipeline) fn parsed_attribute_violates_resvg_contract(
297 element_name: &str,
298 name: &str,
299 value: &str,
300) -> bool {
301 if is_anchor_navigation_attribute(element_name, name) {
302 return admit_mermaid_svg_uri_attribute(value, MermaidSvgUriRepresentation::DomValue)
303 .is_none_or(|normalized| normalized != value);
304 }
305
306 should_drop_attribute(element_name, name, &escape_xml_attr(value))
310}
311
312fn is_event_handler_attribute(name: &str) -> bool {
313 let name = local_name(name.trim());
314 name.len() > 2
315 && name
316 .get(..2)
317 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("on"))
318 && name.as_bytes()[2].is_ascii_alphabetic()
319}
320
321fn is_base_url_attribute(name: &str) -> bool {
322 let Some((prefix, local)) = name.trim().rsplit_once(':') else {
323 return false;
324 };
325 !prefix.eq_ignore_ascii_case("xmlns") && local.eq_ignore_ascii_case("base")
326}
327
328fn is_unsafe_url_attribute(element_name: &str, name: &str, value: &str) -> bool {
329 let attribute_name = local_name(name.trim()).to_ascii_lowercase();
330 match attribute_name.as_str() {
331 "href" => match local_name(element_name).to_ascii_lowercase().as_str() {
332 "a" => is_unsafe_navigation_url_value(value),
333 "image" => !is_safe_data_image_url(value),
334 "feimage" => is_unsafe_render_resource_url_value(value),
335 _ => !is_same_document_fragment(value),
336 },
337 "src" => !is_safe_data_image_url(value),
338 "srcset" => true,
339 _ => false,
340 }
341}
342
343fn is_anchor_navigation_attribute(element_name: &str, name: &str) -> bool {
344 local_name(element_name).eq_ignore_ascii_case("a")
345 && local_name(name.trim()).eq_ignore_ascii_case("href")
346}
347
348fn is_url_function_attribute(name: &str) -> bool {
349 matches!(
350 name,
351 "fill"
352 | "stroke"
353 | "filter"
354 | "clip-path"
355 | "mask"
356 | "marker-start"
357 | "marker-mid"
358 | "marker-end"
359 | "cursor"
360 | "background"
361 | "background-image"
362 )
363}
364
365fn is_unsafe_navigation_url_value(value: &str) -> bool {
366 admit_mermaid_svg_uri_attribute(value, MermaidSvgUriRepresentation::SerializedSvg).is_none()
367}
368
369pub(super) fn is_unsafe_render_resource_url_value(value: &str) -> bool {
370 if is_same_document_fragment(value) {
371 return false;
372 }
373
374 !is_safe_data_image_url(value)
375}
376
377fn is_same_document_fragment(value: &str) -> bool {
378 let value = normalize_url_attr_for_scheme_check(value);
379 is_same_document_fragment_normalized(&value)
380}
381
382fn is_same_document_fragment_normalized(value: &str) -> bool {
383 value
384 .strip_prefix('#')
385 .is_some_and(|fragment| !fragment.is_empty())
386}
387
388fn css_value_violates_url_safety<E>(
389 value: &str,
390 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
391) -> std::result::Result<bool, E> {
392 checkpoint()?;
393 let decoded = merman_core::entities::decode_html_entities_to_unicode(value);
394 checkpoint()?;
395 Ok(sanitize_css_value_with_checkpoints(decoded.as_ref(), checkpoint)?.is_none())
396}
397
398fn normalize_url_attr_for_scheme_check(value: &str) -> String {
399 let decoded = merman_core::entities::decode_html_entities_to_unicode(value);
400 let mut out = String::with_capacity(decoded.len());
401 for ch in decoded.trim().chars() {
402 if !ch.is_whitespace() && !ch.is_control() {
403 out.extend(ch.to_lowercase());
404 }
405 }
406 out
407}
408
409pub(in crate::svg::pipeline) fn is_safe_data_image_url(value: &str) -> bool {
410 let decoded = merman_core::entities::decode_html_entities_to_unicode(value);
411 let Ok(url) = data_url::DataUrl::process(decoded.as_ref()) else {
412 return false;
413 };
414 let mime = url.mime_type();
415 let approved_mime = mime.matches("image", "png")
416 || mime.matches("image", "jpeg")
417 || mime.matches("image", "jpg")
418 || mime.matches("image", "gif")
419 || mime.matches("image", "webp");
420 approved_mime
421 && has_valid_percent_encoding(decoded.as_ref())
422 && url
423 .decode(|_| Ok::<(), std::convert::Infallible>(()))
424 .is_ok()
425}
426
427fn has_valid_percent_encoding(data_url: &str) -> bool {
428 let Some((_, encoded_body)) = data_url.split_once(',') else {
429 return false;
430 };
431 let encoded_body = encoded_body
432 .split_once('#')
433 .map_or(encoded_body, |(body, _)| body);
434 let mut bytes = encoded_body.bytes();
435 while let Some(byte) = bytes.next() {
436 if byte == b'%'
437 && (!bytes.next().is_some_and(|byte| byte.is_ascii_hexdigit())
438 || !bytes.next().is_some_and(|byte| byte.is_ascii_hexdigit()))
439 {
440 return false;
441 }
442 }
443 true
444}
445
446fn normalize_px_attribute(name: &str, value: &str) -> Option<String> {
447 let normalized = local_name(name.trim()).to_ascii_lowercase();
448 let guarded = matches!(
449 normalized.as_str(),
450 "width"
451 | "height"
452 | "x"
453 | "y"
454 | "x1"
455 | "x2"
456 | "y1"
457 | "y2"
458 | "r"
459 | "cx"
460 | "cy"
461 | "rx"
462 | "ry"
463 | "stroke-width"
464 );
465 if !guarded {
466 return None;
467 }
468
469 let trimmed = value.trim();
470 let number = trimmed.strip_suffix("px")?.trim();
471 if number.parse::<f64>().is_ok_and(f64::is_finite) {
472 Some(number.to_string())
473 } else {
474 None
475 }
476}
477
478fn is_start_or_empty_tag(tag: &str, expected: &str) -> bool {
479 let tag = tag.trim_start();
480 if !tag.starts_with('<') || tag.starts_with("</") || tag.starts_with("<!--") {
481 return false;
482 }
483
484 let name = tag[1..]
485 .chars()
486 .take_while(|ch| !ch.is_whitespace() && *ch != '/' && *ch != '>')
487 .collect::<String>();
488 name.eq_ignore_ascii_case(expected)
489}
490
491fn active_svg_element_name(tag: &str) -> Option<&str> {
492 let name = start_tag_name(tag)?;
493 matches_active_svg_element(name).then_some(name)
494}
495
496pub(in crate::svg::pipeline) fn matches_active_svg_element(name: &str) -> bool {
497 matches!(
498 local_name(name).to_ascii_lowercase().as_str(),
499 "animate"
500 | "animatecolor"
501 | "animatemotion"
502 | "animatetransform"
503 | "applet"
504 | "audio"
505 | "canvas"
506 | "discard"
507 | "embed"
508 | "foreignobject"
509 | "form"
510 | "iframe"
511 | "link"
512 | "mpath"
513 | "object"
514 | "script"
515 | "set"
516 | "video"
517 )
518}
519
520fn local_name(name: &str) -> &str {
521 name.rsplit_once(':')
522 .map_or(name, |(_, local_name)| local_name)
523}
524
525fn is_namespace_declaration(name: &str) -> bool {
526 let name = name.trim();
527 name == "xmlns" || name.starts_with("xmlns:")
528}
529
530fn find_close_tag_end<E>(
531 svg: &str,
532 from: usize,
533 name: &str,
534 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
535) -> std::result::Result<Option<usize>, E> {
536 let mut scanner = SvgTagScanner::new(svg);
537 scanner.skip_to(from);
538 while let Some(tag) = scanner.next_with_checkpoints(checkpoint)? {
539 if close_tag_matches(tag.raw(), name) {
540 return Ok(Some(scanner.cursor()));
541 }
542 }
543 checkpoint()?;
544 Ok(None)
545}
546
547fn close_tag_matches(tag: &str, expected: &str) -> bool {
548 let tag = tag.trim_start();
549 if !tag.starts_with("</") {
550 return false;
551 }
552 let name_start = 2;
553 let name_end = name_start
554 + tag[name_start..]
555 .find(|ch: char| ch.is_whitespace() || ch == '>')
556 .unwrap_or(tag.len() - name_start);
557 if name_start >= name_end {
558 return false;
559 }
560 let after_name = tag[name_end..].trim();
561 after_name == ">" && tag[name_start..name_end].eq_ignore_ascii_case(expected)
562}
563
564fn attr_value(tag: &str, name: &str) -> Option<String> {
565 let mut cursor = 0usize;
566 while let Some(attr) = next_svg_quoted_attr(tag, cursor) {
567 if tag[attr.name_start..attr.name_end].eq_ignore_ascii_case(name) {
568 return Some(tag[attr.value_start..attr.value_end].to_string());
569 }
570 cursor = attr.full_end;
571 }
572 None
573}
574
575fn is_missing_or_invalid_rect_dimension(value: Option<&str>) -> bool {
576 let Some(value) = value.map(str::trim) else {
577 return true;
578 };
579 if value.is_empty() {
580 return true;
581 }
582 if let Ok(n) = value.parse::<f64>() {
583 return !n.is_finite() || n <= 0.0;
584 }
585 false
586}
587
588fn is_bad_rect_tag(tag: &str) -> bool {
589 if !is_start_or_empty_tag(tag, "rect") {
590 return false;
591 }
592
593 let width = attr_value(tag, "width");
594 let height = attr_value(tag, "height");
595 is_missing_or_invalid_rect_dimension(width.as_deref())
596 || is_missing_or_invalid_rect_dimension(height.as_deref())
597}
598
599fn sanitize_style_attribute<E>(
600 value: &str,
601 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
602) -> std::result::Result<String, E> {
603 let mut out = Vec::new();
604
605 for (index, (property, value)) in parse_css_declarations(value, checkpoint)?
606 .into_iter()
607 .enumerate()
608 {
609 checkpoint_loop(index, checkpoint)?;
610 let Some(normalized_value) = sanitize_css_value_with_checkpoints(&value, checkpoint)?
611 else {
612 continue;
613 };
614 if normalized_value.is_empty()
615 || is_invalid_style_property_value(&property, &normalized_value)
616 {
617 continue;
618 }
619 if property.to_ascii_lowercase().starts_with("animation") {
620 continue;
621 }
622
623 out.push(format!("{property}:{normalized_value}"));
624 }
625
626 checkpoint()?;
627 Ok(escape_xml_attr(&out.join(";")))
628}
629
630fn parse_css_declarations<E>(
631 value: &str,
632 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
633) -> std::result::Result<Vec<(String, String)>, E> {
634 checkpoint()?;
635 let decoded = merman_core::entities::decode_html_entities_to_unicode(value);
636 checkpoint()?;
637 let mut parser = Parser::new(&decoded);
638 let mut declarations = Vec::new();
639
640 while !parser.is_exhausted() {
641 checkpoint()?;
642 let declaration = parser.parse_until_after(Delimiter::Semicolon, |declaration| {
643 let property = declaration.expect_ident_cloned()?.to_string();
644 declaration.expect_colon()?;
645 let value_start = declaration.position();
646 while declaration.next_including_whitespace().is_ok() {}
649 let value = declaration.slice_from(value_start).trim().to_string();
650 Ok::<_, cssparser::ParseError<()>>((property, value))
651 });
652 checkpoint()?;
653 if let Ok(declaration) = declaration {
654 declarations.push(declaration);
655 }
656 }
657 Ok(declarations)
658}
659
660fn is_invalid_style_property_value(property: &str, value: &str) -> bool {
661 match property.trim().to_ascii_lowercase().as_str() {
662 "fill" | "stroke" | "color" | "flood-color" | "lighting-color" | "stop-color" => {
663 is_mermaid_missing_amount_hsl(value)
664 }
665 "stroke-width" | "stroke-dashoffset" | "fill-opacity" | "stroke-opacity" | "opacity"
666 | "x" | "y" | "width" | "height" | "rx" | "ry" => is_provably_invalid_scalar(value),
667 "transform" => is_invalid_svg_transform(value),
668 "d" => contains_non_finite_numeric_token(value),
669 _ => false,
670 }
671}
672
673fn is_provably_invalid_scalar(value: &str) -> bool {
674 let value = value.trim();
675 if value.eq_ignore_ascii_case("undefined") {
676 return true;
677 }
678
679 let number = ["rem", "px", "%", "em", "ex", "in", "cm", "mm", "pt", "pc"]
680 .into_iter()
681 .find_map(|unit| value.strip_suffix(unit))
682 .unwrap_or(value)
683 .trim();
684 number
685 .parse::<f64>()
686 .is_ok_and(|number| !number.is_finite())
687}
688
689fn is_invalid_svg_transform(value: &str) -> bool {
690 let mut remaining = value.trim();
691 if remaining.eq_ignore_ascii_case("none") {
692 return false;
693 }
694
695 while !remaining.is_empty() {
696 remaining = remaining.trim_start_matches(|ch: char| ch.is_whitespace() || ch == ',');
697 if remaining.is_empty() {
698 break;
699 }
700 let name_end = remaining
701 .find(|ch: char| !ch.is_ascii_alphabetic())
702 .unwrap_or(remaining.len());
703 let name = &remaining[..name_end];
704 let Some(expected) = transform_argument_counts(name) else {
705 return false;
706 };
707 remaining = &remaining[name_end..];
708 let Some(after_open) = remaining.strip_prefix('(') else {
709 return true;
710 };
711 let Some(close) = after_open.find(')') else {
712 return true;
713 };
714 let arguments_text = &after_open[..close];
715 let arguments = arguments_text
716 .split(|ch: char| ch.is_whitespace() || ch == ',')
717 .filter(|argument| !argument.is_empty())
718 .collect::<Vec<_>>();
719 if !expected.contains(&arguments.len())
720 || arguments.iter().any(|argument| {
721 argument
722 .parse::<f64>()
723 .map_or(true, |number| !number.is_finite())
724 })
725 {
726 return true;
727 }
728 remaining = &after_open[close + 1..];
729 }
730 false
731}
732
733fn transform_argument_counts(name: &str) -> Option<&'static [usize]> {
734 match name {
735 "matrix" => Some(&[6]),
736 "translate" | "scale" => Some(&[1, 2]),
737 "rotate" => Some(&[1, 3]),
738 "skewX" | "skewY" => Some(&[1]),
739 _ => None,
740 }
741}
742
743fn contains_non_finite_numeric_token(value: &str) -> bool {
744 ["NaN", "Infinity", "-Infinity", "+Infinity"]
745 .into_iter()
746 .any(|token| contains_delimited_numeric_token(value, token))
747}
748
749fn contains_delimited_numeric_token(value: &str, token: &str) -> bool {
750 value.match_indices(token).any(|(start, _)| {
751 let before = value[..start].chars().next_back();
752 let after = value[start + token.len()..].chars().next();
753 is_numeric_token_boundary(before) && is_numeric_token_boundary(after)
754 })
755}
756
757fn is_numeric_token_boundary(ch: Option<char>) -> bool {
758 ch.is_none_or(|ch| {
759 ch.is_whitespace()
760 || matches!(ch, ',' | '(' | ')')
761 || matches!(
762 ch,
763 'M' | 'm'
764 | 'Z'
765 | 'z'
766 | 'L'
767 | 'l'
768 | 'H'
769 | 'h'
770 | 'V'
771 | 'v'
772 | 'C'
773 | 'c'
774 | 'S'
775 | 's'
776 | 'Q'
777 | 'q'
778 | 'T'
779 | 't'
780 | 'A'
781 | 'a'
782 )
783 })
784}
785
786#[cfg(test)]
787mod tests {
788 use super::{parsed_attribute_violates_resvg_contract, sanitize_element_attributes};
789
790 #[test]
791 fn sanitize_style_attribute_drops_invalid_bare_declarations() {
792 let svg = r#"<svg><path style="undefined; stroke: #333; undefined"/></svg>"#;
793 let out = sanitize_element_attributes(svg);
794
795 assert!(!out.contains("undefined"), "got: {out}");
796 assert!(out.contains(r#"style="stroke:#333""#), "got: {out}");
797 }
798
799 #[test]
800 fn sanitize_element_attributes_drops_rects_without_positive_dimensions() {
801 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>"#;
802 let out = sanitize_element_attributes(svg);
803
804 assert!(!out.contains("<rect/>"), "got: {out}");
805 assert!(!out.contains(r#"width="0""#), "got: {out}");
806 assert!(!out.contains("NaN"), "got: {out}");
807 assert!(!out.contains("<title>bad</title>"), "got: {out}");
808 assert!(
809 out.contains(r#"<rect width="12" height="8"/>"#),
810 "got: {out}"
811 );
812 }
813
814 #[test]
815 fn sanitize_element_attributes_scans_double_quoted_attrs_without_regex() {
816 let svg = r#"<svg><path data-keep = "ok" x = "10px" stroke="" style="transform: rotate(45deg); animation: dash 1s; stroke: #333;"/></svg>"#;
817 let out = sanitize_element_attributes(svg);
818
819 assert!(out.contains(r#"data-keep = "ok""#), "got: {out}");
820 assert!(out.contains(r#" x="10""#), "got: {out}");
821 assert!(!out.contains(r#"stroke="""#), "got: {out}");
822 assert!(
823 out.contains(r#"style="transform:rotate(45);stroke:#333""#),
824 "got: {out}"
825 );
826 assert!(!out.contains("animation"), "got: {out}");
827 }
828
829 #[test]
830 fn sanitize_element_attributes_scans_single_quoted_attrs() {
831 let svg = r#"<svg><path x = '10px' style='animation: dash 1s; stroke: #333;'/></svg>"#;
832 let out = sanitize_element_attributes(svg);
833
834 assert!(out.contains(r#" x="10""#), "got: {out}");
835 assert!(out.contains(r#"style="stroke:#333""#), "got: {out}");
836 assert!(!out.contains("animation"), "got: {out}");
837 }
838
839 #[test]
840 fn sanitize_element_attributes_uses_scanned_attrs_for_bad_rect_detection() {
841 let svg = r#"<svg><rect WIDTH = "12" HEIGHT = "8"/><rect width = "NaN" height = "8"><title>bad</title></rect></svg>"#;
842 let out = sanitize_element_attributes(svg);
843
844 assert!(
845 out.contains(r#"<rect WIDTH = "12" HEIGHT = "8"/>"#),
846 "got: {out}"
847 );
848 assert!(!out.contains("NaN"), "got: {out}");
849 assert!(!out.contains("<title>bad</title>"), "got: {out}");
850 }
851
852 #[test]
853 fn sanitize_element_attributes_strips_active_svg_elements() {
854 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><animate attributeName="x"/><animateMotion><mpath href="#route"/></animateMotion><animateTransform/><animateColor/><set attributeName="fill" to="red"/><discard/><rect width="12" height="8"/></svg>"##;
855 let out = sanitize_element_attributes(svg);
856 let lower = out.to_ascii_lowercase();
857
858 for element in [
859 "script",
860 "svg:script",
861 "iframe",
862 "object",
863 "animate",
864 "animatemotion",
865 "animatetransform",
866 "animatecolor",
867 "mpath",
868 "set",
869 "discard",
870 ] {
871 assert!(!lower.contains(&format!("<{element}")), "got: {out}");
872 }
873 assert!(
874 out.contains(r#"<rect width="12" height="8"/>"#),
875 "got: {out}"
876 );
877 }
878
879 #[test]
880 fn sanitize_element_attributes_strips_event_and_unsafe_url_attrs() {
881 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>"##;
882 let out = sanitize_element_attributes(svg);
883 let lower = out.to_ascii_lowercase();
884
885 assert!(out.contains(r##"href="#local""##), "got: {out}");
886 assert!(out.contains(r##"href="#shape""##), "got: {out}");
887 assert!(out.contains(r##"xlink:href="#shape""##), "got: {out}");
888 assert!(
889 out.contains(r#"href="data:image/png;base64,AAAA""#),
890 "got: {out}"
891 );
892 assert!(!lower.contains("onclick"), "got: {out}");
893 assert!(!lower.contains("svg:onload"), "got: {out}");
894 assert!(!lower.contains("javascript"), "got: {out}");
895 assert!(!lower.contains("data:text/html"), "got: {out}");
896 assert!(!lower.contains("file:///"), "got: {out}");
897 }
898
899 #[test]
900 fn sanitize_element_attributes_decodes_exactly_one_svg_serialization_layer() {
901 let svg = r##"<svg>
902<a href="javascript:alert(1)"><text>decoded by the SVG parser</text></a>
903<a href="jav&#x61;script:alert(2)"><text>literal DOM entity spelling</text></a>
904<a href=""><text>empty href</text></a>
905</svg>"##;
906
907 let out = sanitize_element_attributes(svg);
908
909 assert!(!out.contains(r#"href="javascript:alert(1)""#), "{out}");
910 assert!(
911 out.contains(r#"href="jav&#x61;script:alert(2)""#),
912 "{out}"
913 );
914 assert!(out.contains(r#"href="""#), "{out}");
915 }
916
917 #[test]
918 fn sanitize_element_attributes_normalizes_anchor_dom_values_like_dompurify() {
919 let svg = format!(
920 r##"<svg>
921<a href=" https://example.test/ticket "><text>trimmed</text></a>
922<a href=" "><text>empty</text></a>
923<a href="{}javascript:alert(1)"><text>bom</text></a>
924</svg>"##,
925 '\u{FEFF}'
926 );
927
928 let out = sanitize_element_attributes(&svg);
929
930 assert!(
931 out.contains(r#"href="https://example.test/ticket""#),
932 "{out}"
933 );
934 assert!(out.contains(r#"href="""#), "{out}");
935 assert!(!out.contains("javascript:alert(1)"), "{out}");
936 }
937
938 #[test]
939 fn parsed_resvg_contract_does_not_decode_navigation_entities_twice() {
940 assert!(!parsed_attribute_violates_resvg_contract(
941 "a",
942 "href",
943 "javascript:ticket"
944 ));
945 assert!(parsed_attribute_violates_resvg_contract(
946 "a",
947 "href",
948 "javascript:ticket"
949 ));
950 assert!(parsed_attribute_violates_resvg_contract(
951 "a",
952 "href",
953 " https://example.test "
954 ));
955 }
956
957 #[test]
958 fn sanitize_element_attributes_applies_contract_to_usvg_namespace_aliases() {
959 let svg = r##"<svg xmlns:s="http://www.w3.org/2000/svg" xmlns:q="http://www.w3.org/1999/xlink" xmlns:style="urn:example:ignored">
960<path
961 s:style="animation:spin 1s;stroke:#333"
962 q:fill="url(file:///tmp/paint.svg#paint)"
963 xml:width="NaN"
964 s:transform="rotate(NaN)"
965 q:d="M 0 NaN"
966 xml:x="10px"
967/>
968</svg>"##;
969
970 let out = sanitize_element_attributes(svg);
971
972 assert!(
973 out.contains(r#"xmlns:s="http://www.w3.org/2000/svg""#),
974 "{out}"
975 );
976 assert!(
977 out.contains(r#"xmlns:q="http://www.w3.org/1999/xlink""#),
978 "{out}"
979 );
980 assert!(
981 out.contains(r#"xmlns:style="urn:example:ignored""#),
982 "{out}"
983 );
984 assert!(out.contains(r#"s:style="stroke:#333""#), "{out}");
985 assert!(out.contains(r#"xml:x="10""#), "{out}");
986 for unsafe_value in [
987 "animation",
988 "file:///",
989 "q:fill",
990 "xml:width",
991 "s:transform",
992 "q:d",
993 "NaN",
994 ] {
995 assert!(
996 !out.contains(unsafe_value),
997 "{unsafe_value:?} survived: {out}"
998 );
999 }
1000 }
1001
1002 #[test]
1003 fn sanitize_element_attributes_requires_decodable_data_image_payloads() {
1004 let svg = r#"<svg>
1005<image href="data:image/png;base64,AAAA"/>
1006<image href="data:image/png,%89PNG%0D%0A"/>
1007<image href="data:image/png;base64,AA*A"/>
1008<image href="data:image/png;base64,A==="/>
1009<image href="data:image/png,%"/>
1010<image href="data:image/png,%GG"/>
1011<image href="data:image/png;base64,AA%GG"/>
1012</svg>"#;
1013
1014 let out = sanitize_element_attributes(svg);
1015
1016 assert!(
1017 out.contains(r#"href="data:image/png;base64,AAAA""#),
1018 "{out}"
1019 );
1020 assert!(
1021 out.contains(r#"href="data:image/png,%89PNG%0D%0A""#),
1022 "{out}"
1023 );
1024 for malformed in [
1025 "base64,AA*A",
1026 "base64,A===",
1027 r#"href="data:image/png,%""#,
1028 "data:image/png,%GG",
1029 "base64,AA%GG",
1030 ] {
1031 assert!(!out.contains(malformed), "{malformed:?} survived: {out}");
1032 }
1033 }
1034
1035 #[test]
1036 fn sanitize_element_attributes_closes_non_navigation_render_resources() {
1037 let svg = r##"<svg xmlns:q="http://www.w3.org/1999/xlink" xmlns:b="http://www.w3.org/XML/1998/namespace" xml:base="/tmp/">
1038<a href="https://example.com/docs" q:href="../guide" data-bad="data:image/png;base64,CCCC"><text>links</text></a>
1039<a href="data:image/png;base64,BBBB"><text>data link</text></a>
1040<g b:base="../nested/"><image href="#nested-image"/></g>
1041<image href="/tmp/absolute.png"/>
1042<image href="../relative.png"/>
1043<image href="./sibling.png"/>
1044<image href="bare.png"/>
1045<image href="//example.com/protocol-relative.png"/>
1046<image href="\\server\share\image.png"/>
1047<image href="C:\private\image.png"/>
1048<image href="https://example.com/remote.png"/>
1049<image href="data:image/png;base64,AAAA"/>
1050<image href="data:image/png"/>
1051<image href="d a t a:image/png;base64,EEEE"/>
1052<image href="data:image /png;base64,FFFF"/>
1053<image href="data:image/svg+xml;base64,PHN2Zy8+"/>
1054<image href="#embedded-image"/>
1055<feImage href="#embedded-filter-source"/>
1056<feImage href="data:image/png;base64,GGGG"/>
1057<feImage q:href="../filter.png"/>
1058<use href="#shape" q:href="#shape"/>
1059<use href="data:image/png;base64,DDDD"/>
1060<use href="https://example.com/sprite.svg#shape"/>
1061<textPath href="#path">safe</textPath>
1062<textPath href="../text.svg#path">unsafe</textPath>
1063</svg>"##;
1064
1065 let out = sanitize_element_attributes(svg);
1066
1067 assert!(out.contains(r#"href="https://example.com/docs""#), "{out}");
1068 assert!(out.contains(r#"q:href="../guide""#), "{out}");
1069 assert!(
1070 out.contains(r#"href="data:image/png;base64,AAAA""#),
1071 "{out}"
1072 );
1073 assert!(out.contains(r##"href="#embedded-filter-source""##), "{out}");
1074 assert!(
1075 out.contains(r#"href="data:image/png;base64,GGGG""#),
1076 "{out}"
1077 );
1078 assert!(out.contains(r##"href="#shape""##), "{out}");
1079 assert!(out.contains(r##"q:href="#shape""##), "{out}");
1080 assert!(out.contains(r##"href="#path""##), "{out}");
1081
1082 for external in [
1083 "/tmp/",
1084 "absolute.png",
1085 "relative.png",
1086 "sibling.png",
1087 "bare.png",
1088 "protocol-relative.png",
1089 r"\\server\share",
1090 r"C:\private",
1091 "remote.png",
1092 r#"href="data:image/png""#,
1093 "d a t a:",
1094 "image /png",
1095 "image/svg+xml",
1096 "embedded-image",
1097 "base64,DDDD",
1098 "filter.png",
1099 "sprite.svg",
1100 "text.svg",
1101 "xml:base",
1102 "b:base",
1103 ] {
1104 assert!(!out.contains(external), "{external:?} survived: {out}");
1105 }
1106 assert!(
1107 out.contains(r#"data-bad="data:image/png;base64,CCCC""#),
1108 "{out}"
1109 );
1110 assert!(!out.contains("data:image/png;base64,BBBB"), "{out}");
1111 assert_eq!(sanitize_element_attributes(&out), out);
1112 }
1113
1114 #[test]
1115 fn sanitize_element_attributes_preserves_dompurify_safe_anchor_schemes() {
1116 let svg = r##"<svg>
1117<a href="ftp://example.com/file"><text>ftp</text></a>
1118<a href="ftps://example.com/file"><text>ftps</text></a>
1119<a href="tel:+123"><text>tel</text></a>
1120<a href="callto:+123"><text>call</text></a>
1121<a href="sms:+123"><text>sms</text></a>
1122<a href="cid:part@example.com"><text>cid</text></a>
1123<a href="xmpp:user@example.com"><text>xmpp</text></a>
1124<a href="matrix:r/room:example.com"><text>matrix</text></a>
1125<a href="javascript:alert(1)"><text>script</text></a>
1126</svg>"##;
1127
1128 let out = sanitize_element_attributes(svg);
1129
1130 for href in [
1131 "ftp://example.com/file",
1132 "ftps://example.com/file",
1133 "tel:+123",
1134 "callto:+123",
1135 "sms:+123",
1136 "cid:part@example.com",
1137 "xmpp:user@example.com",
1138 "matrix:r/room:example.com",
1139 ] {
1140 assert!(out.contains(&format!(r#"href="{href}""#)), "{href}: {out}");
1141 }
1142 assert!(!out.contains("javascript:alert(1)"), "{out}");
1143 }
1144
1145 #[test]
1146 fn sanitize_element_attributes_drops_external_css_resource_urls() {
1147 let svg = r##"<svg><path
1148 fill="url(#paint)"
1149 cursor="url(../cursor.svg)"
1150 background="url(https://example.com/background.png)"
1151 style="clip-path:url(#clip);background-image:url(/tmp/background.png);--inline:url(data:image/webp;base64,AAAA);--missing-comma:url(data:image/png);--spaced:url("d a t a:image/png;base64,BBBB");stroke:#333"
1152 /></svg>"##;
1153
1154 let out = sanitize_element_attributes(svg);
1155
1156 assert!(out.contains(r##"fill="url(#paint)""##), "{out}");
1157 assert!(out.contains("clip-path:url(#clip)"), "{out}");
1158 assert!(
1159 out.contains("--inline:url(data:image/webp;base64,AAAA)"),
1160 "{out}"
1161 );
1162 assert!(out.contains("stroke:#333"), "{out}");
1163 assert!(!out.contains("cursor="), "{out}");
1164 assert!(!out.contains("background="), "{out}");
1165 assert!(!out.contains("background-image"), "{out}");
1166 assert!(!out.contains("missing-comma"), "{out}");
1167 assert!(!out.contains("--spaced"), "{out}");
1168 assert!(!out.contains("/tmp/"), "{out}");
1169 assert!(!out.contains("https://"), "{out}");
1170 }
1171
1172 #[test]
1173 fn sanitize_element_attributes_preserves_safe_fragment_ids_with_invalid_token_prefixes() {
1174 let svg = r##"<svg><defs><linearGradient id="undefined"/><linearGradient id="nan"/><linearGradient id="undefined-gradient"/><linearGradient id="nan-stroke"/></defs><circle fill="url(#undefined)" stroke="url(#nan)"/><circle fill="url(#undefined-gradient)" stroke="url(#nan-stroke)"/></svg>"##;
1175
1176 let out = sanitize_element_attributes(svg);
1177
1178 assert!(out.contains(r##"fill="url(#undefined)""##), "{out}");
1179 assert!(out.contains(r##"stroke="url(#nan)""##), "{out}");
1180 assert!(
1181 out.contains(r##"fill="url(#undefined-gradient)""##),
1182 "{out}"
1183 );
1184 assert!(out.contains(r##"stroke="url(#nan-stroke)""##), "{out}");
1185 }
1186
1187 #[test]
1188 fn sanitize_style_preserves_custom_property_identifier_values() {
1189 let svg =
1190 r#"<svg><path style="--state: undefined; --number-kind: NaN; stroke: #333"/></svg>"#;
1191
1192 let out = sanitize_element_attributes(svg);
1193
1194 assert!(out.contains("--state:undefined"), "{out}");
1195 assert!(out.contains("--number-kind:NaN"), "{out}");
1196 assert!(out.contains("stroke:#333"), "{out}");
1197 }
1198
1199 #[test]
1200 fn sanitize_style_keeps_xml_entities_and_nested_semicolons_inside_values() {
1201 let svg = r#"<svg><text style="font-family:"a;b",sans-serif;--data:url(data:image/png;base64,AAAA);stroke:#333"/></svg>"#;
1202
1203 let out = sanitize_element_attributes(svg);
1204 roxmltree::Document::parse(&out).expect("sanitized style must remain valid XML");
1205
1206 assert!(
1207 out.contains("font-family:"a;b",sans-serif"),
1208 "{out}"
1209 );
1210 assert!(
1211 out.contains("--data:url(data:image/png;base64,AAAA)"),
1212 "{out}"
1213 );
1214 assert!(out.contains("stroke:#333"), "{out}");
1215 }
1216
1217 #[test]
1218 fn css_url_tokens_handle_quoted_parentheses_escapes_and_nested_functions() {
1219 let svg = r##"<svg><defs><linearGradient id="paint)close"/></defs><circle fill="url("#paint)close")" stroke="u\72l("j\61vascript:alert(1)")" filter="drop-shadow(0 0 2px u\72l("j\61vascript:alert(1)"))"/></svg>"##;
1220
1221 let out = sanitize_element_attributes(svg);
1222
1223 assert!(
1224 out.contains(r##"fill="url("#paint)close")""##),
1225 "{out}"
1226 );
1227 assert!(!out.contains(" stroke="), "{out}");
1228 assert!(!out.contains(" filter="), "{out}");
1229 assert!(!out.contains(r"u\72l"), "{out}");
1230 }
1231
1232 #[test]
1233 fn sanitize_element_attributes_strips_unsafe_css_url_functions() {
1234 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>"##;
1235 let out = sanitize_element_attributes(svg);
1236 let lower = out.to_ascii_lowercase();
1237
1238 assert!(out.contains(r##"fill="url(#paint)""##), "got: {out}");
1239 assert!(out.contains("clip-path:url(#clip)"), "got: {out}");
1240 assert!(out.contains("stroke:#333"), "got: {out}");
1241 assert!(!lower.contains("javascript"), "got: {out}");
1242 assert!(!lower.contains(r#"stroke="url("#), "got: {out}");
1243 assert!(!lower.contains("filter:"), "got: {out}");
1244 }
1245
1246 #[test]
1247 fn sanitize_style_rewrites_angle_tokens_without_corrupting_text() {
1248 let svg = r##"<svg><path style="transform:rotate(45deg);content:"45deg";background:url(#a45deg);--label:foo45deg"/></svg>"##;
1249 let out = sanitize_element_attributes(svg);
1250
1251 assert!(out.contains("transform:rotate(45)"), "{out}");
1252 assert!(out.contains("content:"45deg""), "{out}");
1253 assert!(out.contains("background:url(#a45deg)"), "{out}");
1254 assert!(out.contains("--label:foo45deg"), "{out}");
1255 }
1256}