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 PermissionDeniedThe 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 responseRegistered 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