CVE-2023-28837: Uncontrolled Resource Consumption (CWE-400)
value: Wagtail is an open source content management system built on Django. Prior to versions 4.1.4 and 4.2.2, a memory exhaustion bug exists in Wagtail's handling of uploaded images and documents. For both images and documents, files are loaded into memory during upload for additional processing. A user with access to upload images or documents through the Wagtail admin interface could upload a file so large that it results in a crash of denial of service.

The vulnerability is not exploitable by an ordinary site visitor without access to the Wagtail admin. It can only be exploited by admin users with permission to upload images or documents.

Image uploads are restricted to 10MB by default, however this validation only happens on the frontend and on the backend after the vulnerable code. 

Patched versions have been released as Wagtail 4.1.4 and Wagtail 4.2.2). Site owners who are unable to upgrade to the new versions are encouraged to add extra protections outside of Wagtail to limit the size of uploaded files.
CWE description: The product does not properly control the allocation and maintenance of a limited resource, thereby enabling an actor to influence the amount of resources consumed, eventually leading to the exhaustion of available resources.
CVSS v3 base score: 4.9
Published: 2023-04-03T17:15Z
Repository: https://github.com/wagtail/wagtail
Language: Python
Files changed: wagtail/images/fields.py, wagtail/images/tests/test_admin_views.py
Commit date: 2023-04-03 12:41:18 +0200
Commit message: Don't load temporary uploaded files into memory

These can be quite large. Instead pass them straight to Willow to have it read just as much as it needs to

Diff: diff --git a/wagtail/images/fields.py b/wagtail/images/fields.py
index 78ed58a7e8..53ec64d653 100644
--- a/wagtail/images/fields.py
+++ b/wagtail/images/fields.py
@@ -144,15 +144,15 @@ class WagtailImageField(ImageField):
         """
         f = FileField.to_python(self, data)
         if f is None:
             return None
 
-        # We need to get a file object for Pillow. When we get a path, we need to open
-        # the file first. And we have to read the data into memory to pass to Willow.
+        # Get the file content ready for Willow
         if hasattr(data, "temporary_file_path"):
-            with open(data.temporary_file_path(), "rb") as fh:
-                file = BytesIO(fh.read())
+            # Django's `TemporaryUploadedFile` is enough of a file to satisfy Willow
+            # Willow doesn't support opening images by path https://github.com/wagtail/Willow/issues/108
+            file = data
         else:
             if hasattr(data, "read"):
                 file = BytesIO(data.read())
             else:
                 file = BytesIO(data["content"])
diff --git a/wagtail/images/tests/test_admin_views.py b/wagtail/images/tests/test_admin_views.py
index dc9a636ace..b9e00de445 100644
--- a/wagtail/images/tests/test_admin_views.py
+++ b/wagtail/images/tests/test_admin_views.py
@@ -1,11 +1,11 @@
 import datetime
 import json
 import urllib
 
 from django.contrib.auth.models import Group, Permission
-from django.core.files.uploadedfile import SimpleUploadedFile
+from django.core.files.uploadedfile import SimpleUploadedFile, TemporaryUploadedFile
 from django.template.defaultfilters import filesizeformat
 from django.template.loader import render_to_string
 from django.test import RequestFactory, TestCase, override_settings
 from django.urls import reverse
 from django.utils.encoding import force_str
@@ -565,10 +565,48 @@ class TestImageAddView(WagtailTestUtils, TestCase):
 
         # Check that the image was created
         images = Image.objects.filter(title="Test image")
         self.assertEqual(images.count(), 1)
 
+    def test_add_temporary_uploaded_file(self):
+        """
+        Test that uploading large files (spooled to the filesystem) work as expected
+        """
+        test_image_file = get_test_image_file()
+        uploaded_file = TemporaryUploadedFile(
+            "test.png", "image/png", test_image_file.size, "utf-8"
+        )
+        uploaded_file.write(test_image_file.file.getvalue())
+        uploaded_file.seek(0)
+
+        response = self.post(
+            {
+                "title": "Test image",
+                "file": uploaded_file,
+            }
+        )
+
+        # Should redirect back to index
+        self.assertRedirects(response, reverse("wagtailimages:index"))
+
+        # Check that the image was created
+        images = Image.objects.filter(title="Test image")
+        self.assertEqual(images.count(), 1)
+
+        # Test that size was populated correctly
+        image = images.first()
+        self.assertEqual(image.width, 640)
+        self.assertEqual(image.height, 480)
+
+        # Test that the file_size/hash fields were set
+        self.assertTrue(image.file_size)
+        self.assertTrue(image.file_hash)
+
+        # Test that it was placed in the root collection
+        root_collection = Collection.get_first_root_node()
+        self.assertEqual(image.collection, root_collection)
+
     @override_settings(
         DEFAULT_FILE_STORAGE="wagtail.test.dummy_external_storage.DummyExternalStorage"
     )
     def test_add_with_external_file_storage(self):
         response = self.post(

Vulnerable code:         # We need to get a file object for Pillow. When we get a path, we need to open
        # the file first. And we have to read the data into memory to pass to Willow.
            with open(data.temporary_file_path(), "rb") as fh:
                file = BytesIO(fh.read())
from django.core.files.uploadedfile import SimpleUploadedFile
Fixed code:         # Get the file content ready for Willow
            # Django's `TemporaryUploadedFile` is enough of a file to satisfy Willow
            # Willow doesn't support opening images by path https://github.com/wagtail/Willow/issues/108
            file = data
from django.core.files.uploadedfile import SimpleUploadedFile, TemporaryUploadedFile
    def test_add_temporary_uploaded_file(self):
        """
        Test that uploading large files (spooled to the filesystem) work as expected
        """
        test_image_file = get_test_image_file()
        uploaded_file = TemporaryUploadedFile(
            "test.png", "image/png", test_image_file.size, "utf-8"
        )
        uploaded_file.write(test_image_file.file.getvalue())
        uploaded_file.seek(0)

        response = self.post(
            {
                "title": "Test image",
                "file": uploaded_file,
            }
        )

        # Should redirect back to index
        self.assertRedirects(response, reverse("wagtailimages:index"))

        # Check that the image was created
        images = Image.objects.filter(title="Test image")
        self.assertEqual(images.count(), 1)

        # Test that size was populated correctly
        image = images.first()
        self.assertEqual(image.width, 640)
        self.assertEqual(image.height, 480)

        # Test that the file_size/hash fields were set
        self.assertTrue(image.file_size)
        self.assertTrue(image.file_hash)

        # Test that it was placed in the root collection
        root_collection = Collection.get_first_root_node()
        self.assertEqual(image.collection, root_collection)