Skip to main content

merman_render/svg/pipeline/builtin/
attr_sanitize.rs

1use 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&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>"##;
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('java&#x73;cript: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}