Media

SVG cleaner

SVG files made safe and small: scripts, event handlers, outside links, foreignObject and editor clutter removed, links unwrapped, numbers rounded and whitespace squeezed, with a report of what was taken out.

Version 1.0.0 · 3 functions · 2 crates

#svg#security#uploads#optimization

Functions

What use svg_clean.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use svg_clean, svg_clean_report from svg_clean (Quillo 0.6).

svg_clean(svg: str, options: any) → stror an error

svg made safe to show and smaller: only drawing elements kept, no scripts, handlers, outside links or editor metadata, numbers rounded to options.precision decimals (2), embedded PNG/JPEG/GIF/WebP images kept unless options.keep_images is false. The cleaned SVG.

svg_clean_report(svg: str, options: any) → anyor an error

What cleaning svg does: { safe, removed: [{ what, why }], size_before, size_after }; safe when nothing dangerous (a script, handler, outside link or foreignObject) was in it.

svg_clean_file(path: str, out_path: str, options: any) → anyor an error

An SVG file cleaned into out_path (folders made): { path, size_before, size_after }. For uploads, before they are served.

Get it

$ quillo install svg_clean               # svg_clean.qwer into this folder$ quillo install svg_clean --example     # and its example, svg_clean.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/svg_clean.qwer$ curl -O https://quillolang.org/plugins/files/svg_clean.qwe$ curl -O https://quillolang.org/plugins/files/svg_clean_sample.svg

Then, in your program, all of it or only what you call:

main.qwe
use svg_clean.qweruse svg_clean, svg_clean_report from svg_clean      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
roxmltree = "0.20"serde_json = { version = "1", features = ["preserve_order"] }

Example

svg_clean.qwe
// A site where users upload their logo as SVG: each file cleaned before it is stored and shown, with a report of what was taken out.////   quillo run svg_clean.qwe      then open http://localhost:8080/reportuse "svg_clean.qwer"route GET /report = svg_clean_report(read_file("svg_clean_sample.svg"), {})?route GET /clean.svg = respond(200, svg_clean(read_file("svg_clean_sample.svg"), { precision: 1 })?, { "content-type": "image/svg+xml" })route GET /checks {    {        file: svg_clean_file("svg_clean_sample.svg", "data/svg_clean/logo.svg", {})?,        rounded: svg_clean("<svg><path d=\"M0.999.5L10.123456-3.5e2\"/></svg>", { precision: 2 })?,        not_svg: svg_clean("<html></html>", {}).error,    }}// POST an SVG: get back the cleaned one, ready to store.route POST /upload = respond(200, svg_clean(request.body, {})?, { "content-type": "image/svg+xml" })
$ quillo run svg_clean.qwe$ curl 'localhost:8080/clean.svg'$ curl 'localhost:8080/report'$ curl 'localhost:8080/checks'

Source

svg_clean.qwer
//! SVG files made safe and small: scripts, event handlers, external links, foreignObject and editor clutter (Inkscape, Illustrator, Sketch metadata, comments) removed, numbers rounded and whitespace squeezed; for logos and icons users upload, and for your own assets.//! version: 1.0.0//!//! [dependencies]//! roxmltree = "0.20"//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Value};const ELEMENTS: &[&str] = &[    "svg", "g", "defs", "symbol", "use", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "text", "tspan", "textPath", "title", "desc",    "linearGradient", "radialGradient", "stop", "clipPath", "mask", "pattern", "marker", "image", "style", "switch", "view",    "filter", "feBlend", "feColorMatrix", "feComponentTransfer", "feComposite", "feConvolveMatrix", "feDiffuseLighting", "feDisplacementMap",    "feDistantLight", "feDropShadow", "feFlood", "feFuncA", "feFuncB", "feFuncG", "feFuncR", "feGaussianBlur", "feImage", "feMerge", "feMergeNode",    "feMorphology", "feOffset", "fePointLight", "feSpecularLighting", "feSpotLight", "feTile", "feTurbulence", "animate", "animateTransform", "animateMotion", "mpath",];const NUMERIC: &[&str] = &["d", "points", "x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "ry", "width", "height", "transform", "viewBox", "stroke-width", "offset", "opacity", "fill-opacity", "stroke-opacity", "fx", "fy", "font-size", "stdDeviation", "dx", "dy", "gradientTransform", "patternTransform"];const XLINK: &str = "http://www.w3.org/1999/xlink";const XML: &str = "http://www.w3.org/XML/1998/namespace";const SVG_NS: &str = "http://www.w3.org/2000/svg";struct Ctx { precision: usize, keep_images: bool, removed: Vec<Value>, uses_xlink: bool }fn esc_attr(s: &str) -> String { s.replace('&', "&amp;").replace('<', "&lt;").replace('"', "&quot;") }fn esc_text(s: &str) -> String { s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;") }fn round_numbers(s: &str, p: usize) -> String {    let c: Vec<char> = s.chars().collect();    let mut out = String::with_capacity(s.len());    let mut i = 0;    while i < c.len() {        let start = i;        let is_num_start = c[i].is_ascii_digit() || (c[i] == '.' && c.get(i + 1).map(|d| d.is_ascii_digit()).unwrap_or(false)) || ((c[i] == '-' || c[i] == '+') && c.get(i + 1).map(|d| d.is_ascii_digit() || *d == '.').unwrap_or(false));        if !is_num_start { out.push(c[i]); i += 1; continue }        if c[i] == '-' || c[i] == '+' { i += 1 }        let mut dot = false;        while i < c.len() && (c[i].is_ascii_digit() || (c[i] == '.' && !dot)) { if c[i] == '.' { dot = true } i += 1 }        if i < c.len() && (c[i] == 'e' || c[i] == 'E') && c.get(i + 1).map(|d| d.is_ascii_digit() || *d == '-' || *d == '+').unwrap_or(false) {            i += 1;            if c[i] == '-' || c[i] == '+' { i += 1 }            while i < c.len() && c[i].is_ascii_digit() { i += 1 }        }        let tok: String = c[start..i].iter().collect();        // Only numbers with more decimals than wanted change: arc flags ("011") and plain integers stay as written.        let decimals = tok.split('.').nth(1).map(|d| d.chars().take_while(|x| x.is_ascii_digit()).count()).unwrap_or(0);        if dot && decimals > p && !tok.contains(['e', 'E']) {            if let Ok(v) = tok.parse::<f64>() {                let mut r = format!("{:.*}", p, v);                if r.contains('.') { r = r.trim_end_matches('0').trim_end_matches('.').to_string() }                if r == "-0" { r = "0".into() }                // Keep numbers apart: "1" then ".5" must not become "1.5".                if !r.contains('.') && c.get(i) == Some(&'.') { r.push(' ') }                out.push_str(&r);                continue;            }        }        out.push_str(&tok);    }    out}fn safe_url(v: &str) -> bool {    let low: String = v.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase();    let mut rest = low.as_str();    while let Some(i) = rest.find("url(") {        let after = rest[i + 4..].trim_start_matches(['\'', '"']);        if !after.starts_with('#') { return false }        rest = &rest[i + 4..];    }    !low.contains("javascript:") && !low.contains("expression(") && !low.contains("@import") && !low.contains("-moz-binding") && !low.contains("behavior:")}fn element(n: roxmltree::Node, ctx: &mut Ctx, out: &mut String, depth: usize) {    let name = n.tag_name().name();    let ns = n.tag_name().namespace().unwrap_or(SVG_NS);    // A link is unwrapped: what it holds stays, the link goes.    if name == "a" && n.tag_name().namespace().unwrap_or(SVG_NS) == SVG_NS && depth > 0 {        ctx.removed.push(json!({ "what": "<a>", "why": "a link (its contents kept)" }));        for c in n.children().filter(|c| c.is_element()) { element(c, ctx, out, depth + 1) }        return;    }    let why = if depth > 256 { Some("nested too deeply") }        else if ns != SVG_NS { Some("not an SVG element (an editor's own)") }        else if name == "script" { Some("a script") }        else if name == "foreignObject" { Some("foreignObject can hold HTML") }        else if name == "metadata" { Some("metadata") }        else if name == "set" { Some("set can change links and handlers") }        else if !ELEMENTS.contains(&name) { Some("not an allowed element") }        else { None };    if let Some(w) = why {        ctx.removed.push(json!({ "what": format!("<{}>", name), "why": w }));        return;    }    if name.starts_with("animate") {        if let Some(a) = n.attribute("attributeName") {            let al = a.to_lowercase();            if al.starts_with("on") || al.contains("href") { ctx.removed.push(json!({ "what": format!("<{} attributeName=\"{}\">", name, a), "why": "animating a link or a handler" })); return }        }    }    let mut attrs = String::new();    for a in n.attributes() {        let (an, ans) = (a.name(), a.namespace());        let full = match ans { Some(XLINK) => format!("xlink:{}", an), Some(XML) => format!("xml:{}", an), Some(_) => { ctx.removed.push(json!({ "what": format!("{} on <{}>", an, name), "why": "an editor's own attribute" })); continue } None => an.to_string() };        let v = a.value();        let lower = an.to_lowercase();        if lower.starts_with("on") { ctx.removed.push(json!({ "what": format!("{} on <{}>", an, name), "why": "an event handler" })); continue }        if an == "href" {            let vl = v.trim().to_lowercase();            let ok = vl.starts_with('#') || (name == "image" && ctx.keep_images && (vl.starts_with("data:image/png") || vl.starts_with("data:image/jpeg") || vl.starts_with("data:image/gif") || vl.starts_with("data:image/webp")));            if !ok { ctx.removed.push(json!({ "what": format!("{}=\"{}\" on <{}>", full, v.chars().take(60).collect::<String>(), name), "why": "a link outside the file" })); continue }            if ans == Some(XLINK) { ctx.uses_xlink = true }        }        if (an == "style" || v.to_lowercase().contains("url(")) && !safe_url(v) { ctx.removed.push(json!({ "what": format!("{} on <{}>", an, name), "why": "a style or url() reaching outside the file" })); continue }        if an.starts_with("data-") && depth > 0 { continue }        let val = if NUMERIC.contains(&an) { round_numbers(v, ctx.precision) } else { v.to_string() };        let val = val.split_whitespace().collect::<Vec<_>>().join(" ");        attrs.push_str(&format!(" {}=\"{}\"", full, esc_attr(&val)));    }    if name == "image" && !attrs.contains("href=") { return }    if depth == 0 {        attrs = format!(" xmlns=\"{}\"{}", SVG_NS, attrs);    }    let mut inner = String::new();    for c in n.children() {        if c.is_element() { element(c, ctx, &mut inner, depth + 1) }        else if c.is_text() {            let t = c.text().unwrap_or("");            if name == "style" {                if !safe_url(t) { ctx.removed.push(json!({ "what": "<style> rules", "why": "a style reaching outside the file" })); continue }                inner.push_str(&esc_text(&t.split_whitespace().collect::<Vec<_>>().join(" ")));            } else if ["text", "tspan", "textPath", "title", "desc"].contains(&name) {                inner.push_str(&esc_text(t));            } else if !t.trim().is_empty() {                inner.push_str(&esc_text(t.trim()));            }        }    }    // Groups and definitions that hold nothing go.    if (name == "g" || name == "defs") && inner.is_empty() && (name == "defs" || attrs.is_empty() || !attrs.contains("id=")) { return }    // A group with no attributes adds nothing: its contents take its place.    if name == "g" && attrs.is_empty() { out.push_str(&inner); return }    if inner.is_empty() { out.push_str(&format!("<{}{}/>", name, attrs)) } else { out.push_str(&format!("<{}{}>{}</{}>", name, attrs, inner, name)) }}fn clean(svg: &str, options: &Value) -> Result<(String, Vec<Value>), String> {    let precision = options.get("precision").and_then(|v| v.as_u64()).unwrap_or(2).min(8) as usize;    let keep_images = options.get("keep_images").and_then(|v| v.as_bool()).unwrap_or(true);    let opt = roxmltree::ParsingOptions { allow_dtd: true, nodes_limit: 2_000_000 };    let doc = roxmltree::Document::parse_with_options(svg, opt).map_err(|e| format!("the SVG is not well-formed XML: {}", e))?;    let root = doc.root_element();    if root.tag_name().name() != "svg" { return Err(format!("the document is <{}>, not <svg>", root.tag_name().name())) }    let mut ctx = Ctx { precision, keep_images, removed: vec![], uses_xlink: false };    let mut out = String::new();    element(root, &mut ctx, &mut out, 0);    if ctx.uses_xlink { out = out.replacen(&format!("<svg xmlns=\"{}\"", SVG_NS), &format!("<svg xmlns=\"{}\" xmlns:xlink=\"{}\"", SVG_NS, XLINK), 1) }    if svg.contains("<!--") { ctx.removed.push(json!({ "what": "comments", "why": "not needed" })) }    Ok((out, ctx.removed))}/// `svg` made safe to show and smaller: only drawing elements kept, no scripts, handlers, outside links or editor metadata, numbers rounded to `options.precision` decimals (2), embedded PNG/JPEG/GIF/WebP images kept unless `options.keep_images` is false. The cleaned SVG.pub fn svg_clean(svg: &str, options: serde_json::Value) -> Result<String, String> {    Ok(clean(svg, &options)?.0)}/// What cleaning `svg` does: `{ safe, removed: [{ what, why }], size_before, size_after }`; `safe` when nothing dangerous (a script, handler, outside link or foreignObject) was in it.pub fn svg_clean_report(svg: &str, options: serde_json::Value) -> Result<Value, String> {    let (out, removed) = clean(svg, &options)?;    let dangerous = removed.iter().any(|r| { let w = r["why"].as_str().unwrap_or(""); w.contains("script") || w.contains("handler") || w.contains("outside") || w.contains("HTML") || w.contains("link") });    Ok(json!({ "safe": !dangerous, "removed": removed, "size_before": svg.len(), "size_after": out.len() }))}/// An SVG file cleaned into `out_path` (folders made): `{ path, size_before, size_after }`. For uploads, before they are served.pub fn svg_clean_file(path: &str, out_path: &str, options: serde_json::Value) -> Result<Value, String> {    let meta = std::fs::metadata(path).map_err(|e| format!("{}: {}", path, e))?;    if meta.len() > 20 * 1024 * 1024 { return Err(format!("{}: larger than 20 MB", path)) }    let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path, e))?;    let (out, _) = clean(&text, &options)?;    if let Some(d) = std::path::Path::new(out_path).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; }    std::fs::write(out_path, &out).map_err(|e| format!("{}: {}", out_path, e))?;    Ok(json!({ "path": out_path, "size_before": text.len(), "size_after": out.len() }))}
Thread
74Useful? Like it, or add to its thread below.

Thread 0 posts

On SVG cleaner: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.