How we added SVG support to Wagtail 5.0
I recently worked on adding support for SVG images to Wagtail. Here's how you can enable SVG support in your Wagtail project.
I recently worked on adding support for SVG images to Wagtail, the open source Content Management System built with Python. This feature was sponsored by YouGov. With this new functionality, you will be able to upload and use SVG images in your Wagtail site, within Wagtail's existing image interface. This feature is due for release in Wagtail 5.0, and the required changes to Willow (Wagtail's image backend) have been released in 1.5. In this blog post I will discuss how to enable SVG support in your Wagtail application, security considerations, SVG sanitisation, and some of the learnings from the implementation.
How to enable SVG support in your Wagtail application
On Wagtail versions >= 5.0: add "svg" to WAGTAILIMAGES_EXTENSIONS:
WAGTAILIMAGES_EXTENSIONS = ["gif", "jpg", "jpeg", "png", "webp", "svg"]
We won't rasterise your SVGs (yet)
The original specification for this feature called for an SVG rasterisation plugin for Willow. The proposed functionality was to rasterise all SVGs at the time of generating an image rendition in Wagtail, to maximise inter-operability with the existing image transformations.
svglib was suggested as a backend for rasterisation, but unfortunately when rendering non-trivial SVGs it generates undesirable artifacts. Some digging revealed that this is a known issue, and likely an issue with the underlying library reportlab, or its underlying library libart.
Below, you can see an example of SVGs rasterised using svglib.
Given that the typical use case for image transformation in a Wagtail application is cropping and/or resizing, and prompted by some discussion with community members, I decided to investigate the feasibility of providing SVG support to Wagtail without a rasterisation backend. It turns out that with a few small assumptions it is possible to crop and resize SVGs by rewriting their viewBox, width, and height attributes. As such, we decided to provide pure Python support for cropping and resizing SVGs in Willow, forgoing rasterisation (for now).
Here are some resources I found useful while working on this:
How to rasterise SVGs using CairoSVG
I do think a rasterisation plugin for Willow would be a useful addition. In my testing, CairoSVG rasterises SVGs accurately. Its API is simple enough that you could add rasterisation to your Wagtail site, as a custom Willow plugin or a custom image filter, without breaking a sweat. Here's an example of how you can rasterise SVGs to PNG using CairoSVG:
from io import BytesIO from cairosvg import svg2png from willow.image import PNGImageFile from willow.svg import SvgImage def rasterise_to_png(image: SvgImage) -> PNGImageFile: # Prepare a file-like object to write the SVG bytes to in_buf = BytesIO() # Write the SVG to the buffer image.write(in_buf) # Prepare a buffer for output out_buf = BytesIO() # Rasterise the SVG to PNG svg2png(file_obj=in_buf, write_to=out_buf) out_buf.seek(0) # Return a Willow PNGImageFile return PNGImageFile(out_buf)
Some of Wagtail's image operations are incompatible with SVGs
As we haven't provided rasterisation support, some of Wagtail's image operations will be incompatible with SVGs. The incompatible operations are format conversion (e.g. format-webp) and bgcolor. If you are adding SVG support to an existing Wagtail site that makes use of these operations, you can use the image template tag's new preserve-svg argument for safety. For example:
{% for picture in pictures %} {% image picture fill-400x400 format-webp preserve-svg %} {% endfor %}
Security considerations
SVG is an application of XML, a format that is the target of a number of known exploits. Wagtail allows a subset of users (i.e. authenticated users with the relevant permissions) to upload and process SVGs, and include them in web pages that are delivered to users. As such, we need to consider security implications both on the server and in the browser.
On the server
Willow uses Python's xml.etree.ElementTree to process SVG files. Per the Python docs, ElementTree:
- Does not retrieve external DocType Declarations;
- Is not vulnerable to external entity expansion; and
- Is not vulnerable to decompression bombs.
However, systems relying on Expat (the XML parser) versions < 2.4.1 may be vulnerable to:
As such, resource exhaustion leading to denial of service is a potential risk. To mitigate this, we make use of defusedxml, which patches Python's XML libs for extra safety.
In the browser
As XML provides a number of methods to load and execute scripts (e.g. inline script tags, data URLs), Cross Site Scripting is a potential risk. Under standard usage, Wagtail will render SVGs in HTML as img tags. SVGs included in HTML by way of img tags are expected to be processed in secure animated mode, and as such scripts will not be executed.
A user may navigate to the actual storage location of the file (e.g. by right click > open image in new tab), causing the image to be rendered as the top-level document. In this case, scripts may be executed. The following steps can be taken to prevent this scenario or mitigate its risks:
- Serve SVGs with Content-Disposition: attachment, so the browser is prompted to download the file rather than rendering it; and
- Set your CSP headers to not allow loading of scripts from unknown domains.
Robin Wood's blog post Protecting against XSS in SVG covers this topic in more detail, and includes interactive examples.
Sanitising SVGs
Two of my colleagues independently recommended investigating svg-hush, a Rust library published by Cloudflare. svg-hush aims to make untrusted SVGs safe for distribution by stripping out potentially dangerous elements. py-svg-hush, available on PyPI (pip install py-svg-hush), is a small package I have published that provides Python bindings to the Rust lib.
Sanitising SVGs on upload
One approach to sanitising SVGs is to process them when they are first saved. To achieve this we can use a custom image model, and override its save method (see the Wagtail docs for the full details on custom image models).
from py_svg_hush import filter_svg from wagtail.images.models import Image, AbstractImage class CustomImage(AbstractImage): def save(self, *args, **kwargs): # Is it a new SVG image? if self.pk is None and self.is_svg(): # Get the image bytes svg_bytes = self.file.read() # Sanitise it with svg-hush clean = filter_svg(svg_bytes) # Write the sanitised SVG back to the file object self.file.seek(0) self.file.truncate() self.file.write(clean) return super().save(*args, **kwargs) admin_form_fields = Image.admin_form_fields
Sanitising SVGs with a custom FilterOperation
Here is an example of how you can use py-svg-hush to sanitise SVGs before serving them as part of a Wagtail page. First, we create a FilterOperation subclass. The required methods are construct and run. Our operation takes no additional arguments, so construct is a noop.
from wagtail.images.image_operations import FilterOperation from willow.svg import SvgImage class FilterSvgOperation(FilterOperation): def construct(self): pass def run(self, willow, image, env): if not isinstance(willow, SvgImage): return willow return filter_svg(willow)
Next, create the filter_svg function. This takes care of unwrapping, processing, and repacking the SVG.
import io from py_svg_hush import filter_svg from willow.svg import SvgImage, SvgWrapper def filter_svg(svg_image: SvgImage) -> SvgImage: # Prepare a file-like object to write the SVG to buf = io.BytesIO() # Unwrap the underlying SvgWrapper, write its ElementTree to the buffer svg_image.image.write(buf) buf.seek(0) # Call the svg-hush wrapper on the SVG bytes. # py_svg_hush will raise a ValueError if the file can't be # parsed. clean = py_svg_hush.filter_svg(buf.read()) # Create a file-like object from the sanitised SVG out = io.BytesIO(clean) return SvgImage(SvgWrapper.from_file(out))
Finally, register the operation with Wagtail (in wagtail_hooks.py):
from wagtail import hooks @hooks.register("register_image_operations") def register_image_operations(): return [("filter_svg", FilterSvgOperation)]
You will now be able to sanitise your SVGs by using the filter_svg argument to the image template tag.
{% image my_svg filter_svg %}