Skip to main content

merman_render/svg/pipeline/builtin/
attr_sanitize.rs

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    // The scanner contract consumes serialized source. Re-serialize this parsed XML value before
307    // reusing it so character references are interpreted exactly once, not once per validation
308    // stage.
309    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            // `expect_no_error_token` recursively descends into every block. The bounded
647            // sanitizer below validates the raw value after this iterative boundary scan.
648            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&colon;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&#x3a;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="jav&#x61;script:alert(1)"><text>decoded by the SVG parser</text></a>
903<a href="jav&amp;#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="jav&#x61;script:alert(1)""#), "{out}");
910        assert!(
911            out.contains(r#"href="jav&amp;#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&colon;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(&quot;d a t a:image/png;base64,BBBB&quot;);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:&quot;a;b&quot;,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:&quot;a;b&quot;,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(&quot;#paint)close&quot;)" stroke="u\72l(&quot;j\61vascript:alert(1)&quot;)" filter="drop-shadow(0 0 2px u\72l(&quot;j\61vascript:alert(1)&quot;))"/></svg>"##;
1220
1221        let out = sanitize_element_attributes(svg);
1222
1223        assert!(
1224            out.contains(r##"fill="url(&quot;#paint)close&quot;)""##),
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('java&#x73;cript: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:&quot;45deg&quot;;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:&quot;45deg&quot;"), "{out}");
1253        assert!(out.contains("background:url(#a45deg)"), "{out}");
1254        assert!(out.contains("--label:foo45deg"), "{out}");
1255    }
1256}