Skip to content
NusaSec
Research / Disclosure

Unbounded filter specs in in wagtail image preview cause DoS

Summary

Wagtail's admin image preview endpoint only receives the global wagtailadmin.access_admin wrapper, then loads any image by ID and runs the path-supplied image filter spec. It does not enforce the image instance permission check used by the adjacent URL generator view.

An authenticated low-privilege admin user with no image permissions can read arbitrary image previews by ID and can trigger expensive rendition processing with attacker-chosen filter specs. The preview path writes to a temporary file and does not require the signed public image URL HMAC

Technical Detail

Root cause

# wagtail/images/admin_urls.py:16-21 @ 913b388a5da8f685bf41415c5c5fae85830eb71d
path(
    "<int:image_id>/generate_url/output/",
    images.URLGeneratorView.as_view(output_only=True),
    name="url_generator_output",
),
path("<int:image_id>/preview/<str:filter_spec>/", images.preview, name="preview"),

The admin URLconf wraps these URLs with only the global admin-access check:

# wagtail/admin/urls/__init__.py:140-141 @ 913b388a5da8f685bf41415c5c5fae85830eb71d
# Add "wagtailadmin.access_admin" permission check
urlpatterns = decorate_urlpatterns(urlpatterns, require_admin_access)

URLGeneratorView then applies an image-level change check:

# wagtail/images/views/images.py:346-357 @ 913b388a5da8f685bf41415c5c5fae85830eb71d
self.object = get_object_or_404(self.model, id=image_id)
 
if not permission_policy.user_has_permission_for_instance(
    request.user, "change", self.object
):
    if self.output_only:
        return HttpResponseForbidden(
            "You do not have permission to generate a URL for this image."
        )
 
    raise PermissionDenied

The adjacent preview function does not repeat that check and directly processes the path-controlled filter spec:

# wagtail/images/views/images.py:430-440 @ 913b388a5da8f685bf41415c5c5fae85830eb71d
def preview(request, image_id, filter_spec):
    image = get_object_or_404(get_image_model(), id=image_id)
 
    try:
        temp_image = SpooledTemporaryFile(max_size=settings.FILE_UPLOAD_MAX_MEMORY_SIZE)
        image = Filter(spec=filter_spec).run(image, temp_image)
        temp_image.seek(0)
        response = FileResponse(temp_image)
        response["Content-Type"] = "image/" + image.format_name
        return response

Registered filters include scale, and the scale operation has no upper bound before resize:

# wagtail/images/wagtail_hooks.py:107-116 @ 913b388a5da8f685bf41415c5c5fae85830eb71d
@hooks.register("register_image_operations")
def register_image_operations():
    return [
        ("original", image_operations.DoNothingOperation),
        ("fill", image_operations.FillOperation),
        ("min", image_operations.MinMaxOperation),
        ("max", image_operations.MinMaxOperation),
        ("width", image_operations.WidthHeightOperation),
        ("height", image_operations.WidthHeightOperation),
        ("scale", image_operations.ScaleOperation),
# wagtail/images/image_operations.py:344-359 @ 913b388a5da8f685bf41415c5c5fae85830eb71d
class ScaleOperation(TransformOperation):
    def construct(self, percent):
        self.percent = float(percent)
 
    def run(self, transform, image):
        image_width, image_height = transform.size
        scale = self.percent / 100
        width = int(image_width * scale)
        height = int(image_height * scale)
 
        # prevent zero width or height, it causes a ValueError on transform.resize
        width = width if width > 0 else 1
        height = height if height > 0 else 1
 
        return transform.resize((width, height))

References: https://github.com/wagtail/wagtail/security/advisories/GHSA-f2p5-j6fg-5cxf