1use quick_xml::events::Event;
2use quick_xml::reader::Reader;
3use std::fmt;
4
5const MAX_SVG_SIZE: usize = 32_768;
7
8const MAX_TITLE_LENGTH: usize = 65;
10
11const 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
23const PROHIBITED_URI_PREFIX: &str = "javascript:";
25
26const EVENT_HANDLER_PREFIX: &str = "on";
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum SvgError {
32 TooLarge(usize),
34 NotSvgRoot,
36 MissingBaseProfile,
38 MissingTitle,
40 TitleTooLong(usize),
42 CommaViewBox,
44 NonSquareAspect,
46 ProhibitedElement(String),
48 EventHandler(String),
50 JavaScriptUri(String),
52 EntityDeclaration,
54 ExternalReference(String),
56 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
98pub fn validate_svg_tiny_ps(svg: &str) -> Result<(), SvgError> {
111 if svg.len() > MAX_SVG_SIZE {
113 return Err(SvgError::TooLarge(svg.len()));
114 }
115
116 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 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_svg_root_attrs(e)?;
147 has_base_profile = true;
148 continue;
149 }
150
151 check_prohibited_element(name_bytes)?;
153
154 if name_bytes == b"title" {
156 in_title = true;
157 title_text.clear();
158 }
159
160 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 found_svg_root = true;
174 check_svg_root_attrs(e)?;
175 has_base_profile = true;
176 continue;
177 }
178
179 check_prohibited_element(name_bytes)?;
181
182 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 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
230fn 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_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 if let Some(ref vb) = viewbox_value {
274 if vb.contains(',') {
276 return Err(SvgError::CommaViewBox);
277 }
278
279 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
293fn 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
304fn 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
320fn 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
601 fn size_exactly_at_limit() {
602 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}