Skip to main content

email_auth/bimi/
svg.rs

1use quick_xml::events::Event;
2use quick_xml::reader::Reader;
3use std::fmt;
4
5/// Maximum SVG file size: 32KB (32,768 bytes).
6const MAX_SVG_SIZE: usize = 32_768;
7
8/// Maximum title length in characters.
9const MAX_TITLE_LENGTH: usize = 65;
10
11/// Prohibited SVG elements.
12const PROHIBITED_ELEMENTS: &[&[u8]] = &[
13    b"script",
14    b"animate",
15    b"animateTransform",
16    b"animateMotion",
17    b"animateColor",
18    b"set",
19    b"image",
20    b"foreignObject",
21];
22
23/// Prohibited URI schemes in attributes.
24const PROHIBITED_URI_PREFIX: &str = "javascript:";
25
26/// Event handler attribute prefix.
27const EVENT_HANDLER_PREFIX: &str = "on";
28
29/// Errors from SVG Tiny PS validation.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum SvgError {
32    /// SVG exceeds 32KB size limit.
33    TooLarge(usize),
34    /// Root element is not <svg>.
35    NotSvgRoot,
36    /// Missing baseProfile="tiny-ps" attribute.
37    MissingBaseProfile,
38    /// Missing <title> element.
39    MissingTitle,
40    /// Title exceeds 65 characters.
41    TitleTooLong(usize),
42    /// viewBox uses comma delimiters instead of spaces.
43    CommaViewBox,
44    /// Non-square aspect ratio.
45    NonSquareAspect,
46    /// Prohibited element found.
47    ProhibitedElement(String),
48    /// Event handler attribute found.
49    EventHandler(String),
50    /// javascript: URI found.
51    JavaScriptUri(String),
52    /// Entity declaration found (XXE prevention).
53    EntityDeclaration,
54    /// External reference found.
55    ExternalReference(String),
56    /// XML parsing error.
57    ParseError(String),
58}
59
60impl fmt::Display for SvgError {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            SvgError::TooLarge(size) => {
64                write!(f, "SVG exceeds 32KB limit: {} bytes", size)
65            }
66            SvgError::NotSvgRoot => write!(f, "root element is not <svg>"),
67            SvgError::MissingBaseProfile => {
68                write!(f, "missing baseProfile=\"tiny-ps\" attribute")
69            }
70            SvgError::MissingTitle => write!(f, "missing <title> element"),
71            SvgError::TitleTooLong(len) => {
72                write!(f, "title exceeds 65 characters: {} chars", len)
73            }
74            SvgError::CommaViewBox => {
75                write!(f, "viewBox uses comma delimiters instead of spaces")
76            }
77            SvgError::NonSquareAspect => write!(f, "non-square aspect ratio"),
78            SvgError::ProhibitedElement(name) => {
79                write!(f, "prohibited element: <{}>", name)
80            }
81            SvgError::EventHandler(attr) => {
82                write!(f, "event handler attribute: {}", attr)
83            }
84            SvgError::JavaScriptUri(attr) => {
85                write!(f, "javascript: URI in attribute: {}", attr)
86            }
87            SvgError::EntityDeclaration => {
88                write!(f, "entity declaration found (XXE prevention)")
89            }
90            SvgError::ExternalReference(detail) => {
91                write!(f, "external reference: {}", detail)
92            }
93            SvgError::ParseError(msg) => write!(f, "XML parse error: {}", msg),
94        }
95    }
96}
97
98/// Validate SVG content against SVG Tiny PS profile.
99///
100/// Checks:
101/// - Size limit (32KB)
102/// - Root element is `<svg>` with `baseProfile="tiny-ps"`
103/// - `<title>` element present (max 65 chars)
104/// - Square aspect ratio via viewBox
105/// - No prohibited elements (script, animate, image, foreignObject, etc.)
106/// - No event handler attributes (`on*`)
107/// - No `javascript:` URIs
108/// - No `<!ENTITY>` declarations (XXE)
109/// - viewBox is space-delimited (not comma)
110pub fn validate_svg_tiny_ps(svg: &str) -> Result<(), SvgError> {
111    // CHK-966/CHK-958: Size limit enforcement BEFORE parsing
112    if svg.len() > MAX_SVG_SIZE {
113        return Err(SvgError::TooLarge(svg.len()));
114    }
115
116    // CHK-963: Check for entity declarations before XML parsing (XXE prevention)
117    if svg.contains("<!ENTITY") {
118        return Err(SvgError::EntityDeclaration);
119    }
120
121    let mut reader = Reader::from_str(svg);
122    reader.config_mut().trim_text(true);
123
124    let mut found_svg_root = false;
125    let mut has_base_profile = false;
126    let mut has_title = false;
127    let mut in_title = false;
128    let mut title_text = String::new();
129    let mut first_element = true;
130
131    loop {
132        match reader.read_event() {
133            Ok(Event::Start(ref e)) => {
134                let local_name = e.local_name();
135                let name_bytes = local_name.as_ref();
136
137                // First element must be <svg>
138                if first_element {
139                    first_element = false;
140                    if name_bytes != b"svg" {
141                        return Err(SvgError::NotSvgRoot);
142                    }
143                    found_svg_root = true;
144
145                    // Check baseProfile and viewBox on <svg>
146                    check_svg_root_attrs(e)?;
147                    has_base_profile = true;
148                    continue;
149                }
150
151                // Check if element is prohibited
152                check_prohibited_element(name_bytes)?;
153
154                // Check for title
155                if name_bytes == b"title" {
156                    in_title = true;
157                    title_text.clear();
158                }
159
160                // Check attributes on Start elements
161                check_element_attrs(e)?;
162            }
163            Ok(Event::Empty(ref e)) => {
164                let local_name = e.local_name();
165                let name_bytes = local_name.as_ref();
166
167                if first_element {
168                    first_element = false;
169                    if name_bytes != b"svg" {
170                        return Err(SvgError::NotSvgRoot);
171                    }
172                    // Self-closing <svg/> — not valid SVG but handle it
173                    found_svg_root = true;
174                    check_svg_root_attrs(e)?;
175                    has_base_profile = true;
176                    continue;
177                }
178
179                // Check if element is prohibited (Event::Empty too!)
180                check_prohibited_element(name_bytes)?;
181
182                // Check attributes on Empty (self-closing) elements
183                check_element_attrs(e)?;
184            }
185            Ok(Event::Text(ref e)) => {
186                if in_title {
187                    match e.unescape() {
188                        Ok(text) => title_text.push_str(&text),
189                        Err(err) => {
190                            return Err(SvgError::ParseError(format!(
191                                "title text decode: {}",
192                                err
193                            )));
194                        }
195                    }
196                }
197            }
198            Ok(Event::End(ref e)) => {
199                let local_name = e.local_name();
200                if local_name.as_ref() == b"title" && in_title {
201                    in_title = false;
202                    has_title = true;
203                    // CHK-955: Title max 65 characters
204                    if title_text.len() > MAX_TITLE_LENGTH {
205                        return Err(SvgError::TitleTooLong(title_text.len()));
206                    }
207                }
208            }
209            Ok(Event::Eof) => break,
210            Err(e) => {
211                return Err(SvgError::ParseError(format!("{}", e)));
212            }
213            _ => {}
214        }
215    }
216
217    if !found_svg_root {
218        return Err(SvgError::NotSvgRoot);
219    }
220    if !has_base_profile {
221        return Err(SvgError::MissingBaseProfile);
222    }
223    if !has_title {
224        return Err(SvgError::MissingTitle);
225    }
226
227    Ok(())
228}
229
230/// Check <svg> root element attributes for baseProfile and viewBox.
231fn check_svg_root_attrs(e: &quick_xml::events::BytesStart<'_>) -> Result<(), SvgError> {
232    let mut found_base_profile = false;
233    let mut viewbox_value: Option<String> = None;
234
235    for attr_result in e.attributes() {
236        match attr_result {
237            Ok(attr) => {
238                let key = attr.key.local_name();
239                let key_bytes = key.as_ref();
240
241                if key_bytes == b"baseProfile" {
242                    let val = attr
243                        .unescape_value()
244                        .map_err(|err| SvgError::ParseError(format!("baseProfile: {}", err)))?;
245                    if val.as_ref() == "tiny-ps" {
246                        found_base_profile = true;
247                    } else {
248                        return Err(SvgError::MissingBaseProfile);
249                    }
250                }
251
252                if key_bytes == b"viewBox" {
253                    let val = attr
254                        .unescape_value()
255                        .map_err(|err| SvgError::ParseError(format!("viewBox: {}", err)))?;
256                    viewbox_value = Some(val.to_string());
257                }
258
259                // Check for event handlers and javascript: URIs on root too
260                check_attr_security(key_bytes, &attr)?;
261            }
262            Err(err) => {
263                return Err(SvgError::ParseError(format!("attribute: {}", err)));
264            }
265        }
266    }
267
268    if !found_base_profile {
269        return Err(SvgError::MissingBaseProfile);
270    }
271
272    // Validate viewBox if present
273    if let Some(ref vb) = viewbox_value {
274        // CHK-957: viewBox MUST be space-delimited, NOT comma-delimited
275        if vb.contains(',') {
276            return Err(SvgError::CommaViewBox);
277        }
278
279        // CHK-956: Square aspect ratio (width == height)
280        let parts: Vec<&str> = vb.split_whitespace().collect();
281        if parts.len() == 4 {
282            if let (Ok(w), Ok(h)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>()) {
283                if (w - h).abs() > f64::EPSILON && w > 0.0 && h > 0.0 {
284                    return Err(SvgError::NonSquareAspect);
285                }
286            }
287        }
288    }
289
290    Ok(())
291}
292
293/// Check if an element name is prohibited.
294fn check_prohibited_element(name: &[u8]) -> Result<(), SvgError> {
295    for &prohibited in PROHIBITED_ELEMENTS {
296        if name.eq_ignore_ascii_case(prohibited) {
297            let name_str = String::from_utf8_lossy(name).to_string();
298            return Err(SvgError::ProhibitedElement(name_str));
299        }
300    }
301    Ok(())
302}
303
304/// Check attributes on a Start or Empty element for security issues.
305fn check_element_attrs(e: &quick_xml::events::BytesStart<'_>) -> Result<(), SvgError> {
306    for attr_result in e.attributes() {
307        match attr_result {
308            Ok(attr) => {
309                let key = attr.key.local_name();
310                check_attr_security(key.as_ref(), &attr)?;
311            }
312            Err(err) => {
313                return Err(SvgError::ParseError(format!("attribute: {}", err)));
314            }
315        }
316    }
317    Ok(())
318}
319
320/// Check a single attribute for event handlers and javascript: URIs.
321fn check_attr_security(
322    key_bytes: &[u8],
323    attr: &quick_xml::events::attributes::Attribute<'_>,
324) -> Result<(), SvgError> {
325    let key_str = std::str::from_utf8(key_bytes).unwrap_or("");
326
327    // CHK-959/CHK-1014: Event handler attributes (on*)
328    if key_str.to_ascii_lowercase().starts_with(EVENT_HANDLER_PREFIX) && key_str.len() > 2 {
329        return Err(SvgError::EventHandler(key_str.to_string()));
330    }
331
332    // CHK-964: javascript: URIs in href, xlink:href, src, etc.
333    let val = attr
334        .unescape_value()
335        .map_err(|err| SvgError::ParseError(format!("attr value: {}", err)))?;
336    let val_trimmed = val.trim().to_ascii_lowercase();
337    if val_trimmed.starts_with(PROHIBITED_URI_PREFIX) {
338        return Err(SvgError::JavaScriptUri(key_str.to_string()));
339    }
340
341    Ok(())
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    // ─── CHK-1008: Valid SVG Tiny PS → pass ──────────────────────────
349
350    #[test]
351    fn valid_svg_tiny_ps() {
352        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
353  <title>Example Logo</title>
354  <rect width="100" height="100" fill="red"/>
355</svg>"#;
356        assert!(validate_svg_tiny_ps(svg).is_ok());
357    }
358
359    // ─── CHK-1009: Missing baseProfile → fail ────────────────────────
360
361    #[test]
362    fn missing_base_profile() {
363        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" viewBox="0 0 100 100">
364  <title>Logo</title>
365  <rect width="100" height="100" fill="red"/>
366</svg>"#;
367        assert_eq!(
368            validate_svg_tiny_ps(svg),
369            Err(SvgError::MissingBaseProfile)
370        );
371    }
372
373    // ─── CHK-1010: Contains <script> → fail ──────────────────────────
374
375    #[test]
376    fn script_element_prohibited() {
377        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
378  <title>Logo</title>
379  <script>alert('xss')</script>
380</svg>"#;
381        assert_eq!(
382            validate_svg_tiny_ps(svg),
383            Err(SvgError::ProhibitedElement("script".into()))
384        );
385    }
386
387    // ─── CHK-1011: Exceeds 32KB → fail ───────────────────────────────
388
389    #[test]
390    fn exceeds_32kb() {
391        let svg = "x".repeat(MAX_SVG_SIZE + 1);
392        match validate_svg_tiny_ps(&svg) {
393            Err(SvgError::TooLarge(size)) => assert_eq!(size, MAX_SVG_SIZE + 1),
394            other => panic!("expected TooLarge, got {:?}", other),
395        }
396    }
397
398    // ─── CHK-1012: Missing <title> → fail ────────────────────────────
399
400    #[test]
401    fn missing_title() {
402        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
403  <rect width="100" height="100" fill="red"/>
404</svg>"#;
405        assert_eq!(validate_svg_tiny_ps(svg), Err(SvgError::MissingTitle));
406    }
407
408    // ─── CHK-1013: Comma-delimited viewBox → fail ────────────────────
409
410    #[test]
411    fn comma_viewbox() {
412        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0,0,100,100">
413  <title>Logo</title>
414  <rect width="100" height="100" fill="red"/>
415</svg>"#;
416        assert_eq!(validate_svg_tiny_ps(svg), Err(SvgError::CommaViewBox));
417    }
418
419    // ─── CHK-1014: Event handler on self-closing element → fail ──────
420
421    #[test]
422    fn event_handler_self_closing() {
423        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
424  <title>Logo</title>
425  <rect onclick="alert(1)" width="100" height="100" fill="red"/>
426</svg>"#;
427        assert_eq!(
428            validate_svg_tiny_ps(svg),
429            Err(SvgError::EventHandler("onclick".into()))
430        );
431    }
432
433    // ─── CHK-1015: javascript: URI in href → fail ────────────────────
434
435    #[test]
436    fn javascript_uri_in_href() {
437        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
438  <title>Logo</title>
439  <a href="javascript:alert(1)"><rect width="100" height="100"/></a>
440</svg>"#;
441        assert_eq!(
442            validate_svg_tiny_ps(svg),
443            Err(SvgError::JavaScriptUri("href".into()))
444        );
445    }
446
447    // ─── CHK-1016: <animate> element → fail ──────────────────────────
448
449    #[test]
450    fn animate_element_prohibited() {
451        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
452  <title>Logo</title>
453  <animate attributeName="opacity" from="1" to="0" dur="1s"/>
454</svg>"#;
455        assert_eq!(
456            validate_svg_tiny_ps(svg),
457            Err(SvgError::ProhibitedElement("animate".into()))
458        );
459    }
460
461    // ─── CHK-1017: <image> element → fail ────────────────────────────
462
463    #[test]
464    fn image_element_prohibited() {
465        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
466  <title>Logo</title>
467  <image href="data:image/png;base64,abc" width="100" height="100"/>
468</svg>"#;
469        assert_eq!(
470            validate_svg_tiny_ps(svg),
471            Err(SvgError::ProhibitedElement("image".into()))
472        );
473    }
474
475    // ─── CHK-1018: <foreignObject> element → fail ────────────────────
476
477    #[test]
478    fn foreign_object_prohibited() {
479        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
480  <title>Logo</title>
481  <foreignObject width="100" height="100">
482    <div xmlns="http://www.w3.org/1999/xhtml">Hello</div>
483  </foreignObject>
484</svg>"#;
485        assert_eq!(
486            validate_svg_tiny_ps(svg),
487            Err(SvgError::ProhibitedElement("foreignObject".into()))
488        );
489    }
490
491    // ─── CHK-1019: Title exceeding 65 characters → fail ──────────────
492
493    #[test]
494    fn title_too_long() {
495        let long_title = "A".repeat(66);
496        let svg = format!(
497            r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
498  <title>{}</title>
499  <rect width="100" height="100" fill="red"/>
500</svg>"#,
501            long_title
502        );
503        assert_eq!(
504            validate_svg_tiny_ps(&svg),
505            Err(SvgError::TitleTooLong(66))
506        );
507    }
508
509    #[test]
510    fn title_exactly_65_chars_ok() {
511        let title = "A".repeat(65);
512        let svg = format!(
513            r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
514  <title>{}</title>
515  <rect width="100" height="100" fill="red"/>
516</svg>"#,
517            title
518        );
519        assert!(validate_svg_tiny_ps(&svg).is_ok());
520    }
521
522    // ─── CHK-1020: Entity declaration → fail (XXE prevention) ────────
523
524    #[test]
525    fn entity_declaration_xxe() {
526        let svg = r#"<?xml version="1.0"?>
527<!DOCTYPE svg [
528  <!ENTITY xxe SYSTEM "file:///etc/passwd">
529]>
530<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
531  <title>Logo</title>
532  <text>&xxe;</text>
533</svg>"#;
534        assert_eq!(validate_svg_tiny_ps(svg), Err(SvgError::EntityDeclaration));
535    }
536
537    // ─── Additional: Non-square aspect ratio ─────────────────────────
538
539    #[test]
540    fn non_square_aspect_ratio() {
541        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 200 100">
542  <title>Logo</title>
543  <rect width="200" height="100" fill="red"/>
544</svg>"#;
545        assert_eq!(validate_svg_tiny_ps(svg), Err(SvgError::NonSquareAspect));
546    }
547
548    // ─── Additional: Event handler on Start element ──────────────────
549
550    #[test]
551    fn event_handler_start_element() {
552        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
553  <title>Logo</title>
554  <g onclick="alert(1)"><rect width="100" height="100"/></g>
555</svg>"#;
556        assert_eq!(
557            validate_svg_tiny_ps(svg),
558            Err(SvgError::EventHandler("onclick".into()))
559        );
560    }
561
562    // ─── Additional: animateTransform prohibited ─────────────────────
563
564    #[test]
565    fn animate_transform_prohibited() {
566        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100">
567  <title>Logo</title>
568  <animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="1s"/>
569</svg>"#;
570        assert_eq!(
571            validate_svg_tiny_ps(svg),
572            Err(SvgError::ProhibitedElement("animateTransform".into()))
573        );
574    }
575
576    // ─── Additional: Root not <svg> ──────────────────────────────────
577
578    #[test]
579    fn root_not_svg() {
580        let svg = r#"<div>Not an SVG</div>"#;
581        assert_eq!(validate_svg_tiny_ps(svg), Err(SvgError::NotSvgRoot));
582    }
583
584    // ─── Additional: Wrong baseProfile value ─────────────────────────
585
586    #[test]
587    fn wrong_base_profile_value() {
588        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="full" viewBox="0 0 100 100">
589  <title>Logo</title>
590  <rect width="100" height="100" fill="red"/>
591</svg>"#;
592        assert_eq!(
593            validate_svg_tiny_ps(svg),
594            Err(SvgError::MissingBaseProfile)
595        );
596    }
597
598    // ─── Additional: Size exactly at limit ───────────────────────────
599
600    #[test]
601    fn size_exactly_at_limit() {
602        // Build a valid SVG that's exactly MAX_SVG_SIZE bytes
603        let prefix = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100"><title>L</title><rect fill="r"#;
604        let suffix = r#"ed"/></svg>"#;
605        let padding_needed = MAX_SVG_SIZE - prefix.len() - suffix.len();
606        let padding = " ".repeat(padding_needed);
607        let svg = format!("{}{}{}", prefix, padding, suffix);
608        assert_eq!(svg.len(), MAX_SVG_SIZE);
609        assert!(validate_svg_tiny_ps(&svg).is_ok());
610    }
611}