Josh Munn's Website
How we added SVG support to Wagtail 5.0 [2023-04-27 Thu]

Home

Table of Contents

I recently worked1, 2 on adding support for SVG images to Wagtail, the open source Content Management System built with Python. 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.

This post also appears on wagtail.org.

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 issue3, and likely an issue with the underlying library reportlab, or its underlying library libart4.

svglib.png

Figure 1: 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)

To enthusiastic contributors: before spending any time working on a CairoSVG backed rasterisation feature for Willow, it would be worth discussing it with the maintainers of Willow and Wagtail. CairoSVG is released under the LGPL license, and there is some precedent5 of not using GPL'd libraries in Wagtail.

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 %}

Only supported operations will be applied to SVGs processed by image tags with preserve-svg.

Security considerations

SVG is an application of XML, a format that is the target of a number of known exploits6. 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 docs7, ElementTree:

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 executed8.

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. It has not been used in production yet, so caveat emptor.

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

The benefit of this approach is that an SVG only needs to be sanitised once - any renditions generated for an image will not need to be re-sanitised.

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.

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.

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 %}

Footnotes: