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/documents/models.py, wagtail/documents/tests/test_models.py, wagtail/images/models.py, wagtail/images/tests/test_models.py, wagtail/tests/test_utils.py, wagtail/utils/file.py
Commit date: 2023-04-03 12:41:18 +0200
Commit message: Don't load images / documents into memory when calculating their hash

This can be done in chunks, which prevents crashes when uploading large files.

Diff: diff --git a/wagtail/documents/models.py b/wagtail/documents/models.py
index a5309df8b6..cb792f7777 100644
--- a/wagtail/documents/models.py
+++ b/wagtail/documents/models.py
@@ -1,6 +1,5 @@
-import hashlib
 import os.path
 import urllib
 from contextlib import contextmanager
 from mimetypes import guess_type
 
@@ -13,10 +12,11 @@ from django.utils.translation import gettext_lazy as _
 from taggit.managers import TaggableManager
 
 from wagtail.models import CollectionMember, ReferenceIndex
 from wagtail.search import index
 from wagtail.search.queryset import SearchableQuerySetMixin
+from wagtail.utils.file import hash_filelike
 
 
 class DocumentQuerySet(SearchableQuerySetMixin, models.QuerySet):
     pass
 
@@ -120,18 +120,17 @@ class AbstractDocument(CollectionMember, index.Indexed, models.Model):
 
             self.save(update_fields=["file_size"])
 
         return self.file_size
 
-    def _set_file_hash(self, file_contents):
-        self.file_hash = hashlib.sha1(file_contents).hexdigest()
+    def _set_file_hash(self):
+        with self.open_file() as f:
+            self.file_hash = hash_filelike(f)
 
     def get_file_hash(self):
         if self.file_hash == "":
-            with self.open_file() as f:
-                self._set_file_hash(f.read())
-
+            self._set_file_hash()
             self.save(update_fields=["file_hash"])
 
         return self.file_hash
 
     def _set_document_file_metadata(self):
@@ -139,11 +138,11 @@ class AbstractDocument(CollectionMember, index.Indexed, models.Model):
 
         # Set new document file size
         self.file_size = self.file.size
 
         # Set new document file hash
-        self._set_file_hash(self.file.read())
+        self._set_file_hash()
         self.file.seek(0)
 
     def __str__(self):
         return self.title
 
diff --git a/wagtail/documents/tests/test_models.py b/wagtail/documents/tests/test_models.py
index eecb512c05..2a1a2d6fdb 100644
--- a/wagtail/documents/tests/test_models.py
+++ b/wagtail/documents/tests/test_models.py
@@ -124,10 +124,23 @@ class TestDocumentFilenameProperties(TestCase):
         self.assertEqual("application/pdf", self.pdf_document.content_type)
         self.assertEqual(
             "application/octet-stream", self.extensionless_document.content_type
         )
 
+    def test_file_hash(self):
+        self.assertEqual(
+            self.document.get_file_hash(), "7d8c4778b182e4f3bd442408c64a6e22a4b0ed85"
+        )
+        self.assertEqual(
+            self.pdf_document.get_file_hash(),
+            "7d8c4778b182e4f3bd442408c64a6e22a4b0ed85",
+        )
+        self.assertEqual(
+            self.extensionless_document.get_file_hash(),
+            "7d8c4778b182e4f3bd442408c64a6e22a4b0ed85",
+        )
+
     def test_content_disposition(self):
         self.assertEqual(
             """attachment; filename=example.doc; filename*=UTF-8''example.doc""",
             self.document.content_disposition,
         )
diff --git a/wagtail/images/models.py b/wagtail/images/models.py
index 6b05f5a9dd..fc968a801b 100644
--- a/wagtail/images/models.py
+++ b/wagtail/images/models.py
@@ -36,10 +36,11 @@ from wagtail.images.image_operations import (
 )
 from wagtail.images.rect import Rect
 from wagtail.models import CollectionMember, ReferenceIndex
 from wagtail.search import index
 from wagtail.search.queryset import SearchableQuerySetMixin
+from wagtail.utils.file import hash_filelike
 
 logger = logging.getLogger("wagtail.images")
 
 
 IMAGE_FORMAT_EXTENSIONS = {
@@ -265,18 +266,17 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
         max_length=40, blank=True, editable=False, db_index=True
     )
 
     objects = ImageQuerySet.as_manager()
 
-    def _set_file_hash(self, file_contents):
-        self.file_hash = hashlib.sha1(file_contents).hexdigest()
+    def _set_file_hash(self):
+        with self.open_file() as f:
+            self.file_hash = hash_filelike(f)
 
     def get_file_hash(self):
         if self.file_hash == "":
-            with self.open_file() as f:
-                self._set_file_hash(f.read())
-
+            self._set_file_hash()
             self.save(update_fields=["file_hash"])
 
         return self.file_hash
 
     def _set_image_file_metadata(self):
@@ -284,11 +284,11 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
 
         # Set new image file size
         self.file_size = self.file.size
 
         # Set new image file hash
-        self._set_file_hash(self.file.read())
+        self._set_file_hash()
         self.file.seek(0)
 
     def get_upload_to(self, filename):
         folder_name = "original_images"
         filename = self.file.field.storage.get_valid_name(filename)
diff --git a/wagtail/images/tests/test_models.py b/wagtail/images/tests/test_models.py
index b6cc71e53e..58a558158e 100644
--- a/wagtail/images/tests/test_models.py
+++ b/wagtail/images/tests/test_models.py
@@ -110,10 +110,15 @@ class TestImage(TestCase):
     def test_get_file_size_on_missing_file_raises_sourceimageioerror(self):
         self.image.file.delete(save=False)
         with self.assertRaises(SourceImageIOError):
             self.image.get_file_size()
 
+    def test_file_hash(self):
+        self.assertEqual(
+            self.image.get_file_hash(), "4dd0211870e130b7e1690d2ec53c499a54a48fef"
+        )
+
 
 class TestImageQuerySet(TestCase):
     def test_search_method(self):
         # Create an image for running tests on
         image = Image.objects.create(
diff --git a/wagtail/tests/test_utils.py b/wagtail/tests/test_utils.py
index 2987d5d871..acf397e9c3 100644
--- a/wagtail/tests/test_utils.py
+++ b/wagtail/tests/test_utils.py
@@ -1,10 +1,13 @@
 # -*- coding: utf-8 -*
 import pickle
+from io import BytesIO, StringIO
+from pathlib import Path
 
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
+from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test import SimpleTestCase, TestCase, override_settings
 from django.utils.text import slugify
 from django.utils.translation import _trans
 from django.utils.translation import gettext_lazy as _
 
@@ -21,10 +24,11 @@ from wagtail.coreutils import (
     multigetattr,
     safe_snake_case,
     string_to_ascii,
 )
 from wagtail.models import Page, Site
+from wagtail.utils.file import hash_filelike
 from wagtail.utils.utils import deep_update
 
 
 class TestCamelCaseToUnderscore(TestCase):
     def test_camelcase_to_underscore(self):
@@ -506,5 +510,60 @@ class TestDeepUpdate(TestCase):
                     "temperature": "hot",
                 },
                 "starship": "enterprise",
             },
         )
+
+
+class HashFileLikeTestCase(SimpleTestCase):
+    test_file = Path.cwd() / "LICENSE"
+
+    def test_hashes_io(self):
+        self.assertEqual(
+            hash_filelike(BytesIO(b"test")), "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"
+        )
+        self.assertEqual(
+            hash_filelike(StringIO("test")), "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"
+        )
+
+    def test_hashes_file(self):
+        with self.test_file.open(mode="r") as f:
+            self.assertEqual(
+                hash_filelike(f), "9e58400061ca660ef7b5c94338a5205627c77eda"
+            )
+
+    def test_hashes_file_bytes(self):
+        with self.test_file.open(mode="rb") as f:
+            self.assertEqual(
+                hash_filelike(f), "9e58400061ca660ef7b5c94338a5205627c77eda"
+            )
+
+    def test_hashes_django_uploaded_file(self):
+        """
+        Check Django's file shims can be hashed as-is.
+        `SimpleUploadedFile` inherits the base `UploadedFile`, but is easiest to test against
+        """
+        self.assertEqual(
+            hash_filelike(SimpleUploadedFile("example.txt", b"test")),
+            "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
+        )
+
+    def test_hashes_large_file(self):
+        class FakeLargeFile:
+            """
+            A class that pretends to be a huge file (~1.3GB)
+            """
+
+            def __init__(self):
+                self.iterations = 20000
+
+            def read(self, bytes):
+                self.iterations -= 1
+                if not self.iterations:
+                    return b""
+
+                return b"A" * bytes
+
+        self.assertEqual(
+            hash_filelike(FakeLargeFile()),
+            "187cc1db32624dccace20d042f6d631f1a483020",
+        )
diff --git a/wagtail/utils/file.py b/wagtail/utils/file.py
new file mode 100644
index 0000000000..cb0a706241
--- /dev/null
+++ b/wagtail/utils/file.py
@@ -0,0 +1,35 @@
+from hashlib import sha1
+from io import UnsupportedOperation
+
+from django.utils.encoding import force_bytes
+
+HASH_READ_SIZE = 65536  # 64k
+
+
+def hash_filelike(filelike):
+    """
+    Compute the hash of a file-like object, without loading it all into memory.
+    """
+    file_pos = 0
+    if hasattr(filelike, "tell"):
+        file_pos = filelike.tell()
+
+    try:
+        # Reset file handler to the start of the file so we hash it all
+        filelike.seek(0)
+    except (AttributeError, UnsupportedOperation):
+        pass
+
+    hasher = sha1()
+    while True:
+        data = filelike.read(HASH_READ_SIZE)
+        if not data:
+            break
+        # Use `force_bytes` to account for files opened as text
+        hasher.update(force_bytes(data))
+
+    if hasattr(filelike, "seek"):
+        # Reset the file handler to where it was before
+        filelike.seek(file_pos)
+
+    return hasher.hexdigest()

Vulnerable code: import hashlib
    def _set_file_hash(self, file_contents):
        self.file_hash = hashlib.sha1(file_contents).hexdigest()
            with self.open_file() as f:
                self._set_file_hash(f.read())

        self._set_file_hash(self.file.read())
    def _set_file_hash(self, file_contents):
        self.file_hash = hashlib.sha1(file_contents).hexdigest()
            with self.open_file() as f:
                self._set_file_hash(f.read())

        self._set_file_hash(self.file.read())
Fixed code: from wagtail.utils.file import hash_filelike
    def _set_file_hash(self):
        with self.open_file() as f:
            self.file_hash = hash_filelike(f)
            self._set_file_hash()
        self._set_file_hash()
    def test_file_hash(self):
        self.assertEqual(
            self.document.get_file_hash(), "7d8c4778b182e4f3bd442408c64a6e22a4b0ed85"
        )
        self.assertEqual(
            self.pdf_document.get_file_hash(),
            "7d8c4778b182e4f3bd442408c64a6e22a4b0ed85",
        )
        self.assertEqual(
            self.extensionless_document.get_file_hash(),
            "7d8c4778b182e4f3bd442408c64a6e22a4b0ed85",
        )

from wagtail.utils.file import hash_filelike
    def _set_file_hash(self):
        with self.open_file() as f:
            self.file_hash = hash_filelike(f)
            self._set_file_hash()
        self._set_file_hash()
    def test_file_hash(self):
        self.assertEqual(
            self.image.get_file_hash(), "4dd0211870e130b7e1690d2ec53c499a54a48fef"
        )

from io import BytesIO, StringIO
from pathlib import Path
from django.core.files.uploadedfile import SimpleUploadedFile
from wagtail.utils.file import hash_filelike


class HashFileLikeTestCase(SimpleTestCase):
    test_file = Path.cwd() / "LICENSE"

    def test_hashes_io(self):
        self.assertEqual(
            hash_filelike(BytesIO(b"test")), "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"
        )
        self.assertEqual(
            hash_filelike(StringIO("test")), "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"
        )

    def test_hashes_file(self):
        with self.test_file.open(mode="r") as f:
            self.assertEqual(
                hash_filelike(f), "9e58400061ca660ef7b5c94338a5205627c77eda"
            )

    def test_hashes_file_bytes(self):
        with self.test_file.open(mode="rb") as f:
            self.assertEqual(
                hash_filelike(f), "9e58400061ca660ef7b5c94338a5205627c77eda"
            )

    def test_hashes_django_uploaded_file(self):
        """
        Check Django's file shims can be hashed as-is.
        `SimpleUploadedFile` inherits the base `UploadedFile`, but is easiest to test against
        """
        self.assertEqual(
            hash_filelike(SimpleUploadedFile("example.txt", b"test")),
            "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
        )

    def test_hashes_large_file(self):
        class FakeLargeFile:
            """
            A class that pretends to be a huge file (~1.3GB)
            """

            def __init__(self):
                self.iterations = 20000

            def read(self, bytes):
                self.iterations -= 1
                if not self.iterations:
                    return b""

                return b"A" * bytes

        self.assertEqual(
            hash_filelike(FakeLargeFile()),
            "187cc1db32624dccace20d042f6d631f1a483020",
        )
from hashlib import sha1
from io import UnsupportedOperation

from django.utils.encoding import force_bytes

HASH_READ_SIZE = 65536  # 64k


def hash_filelike(filelike):
    """
    Compute the hash of a file-like object, without loading it all into memory.
    """
    file_pos = 0
    if hasattr(filelike, "tell"):
        file_pos = filelike.tell()

    try:
        # Reset file handler to the start of the file so we hash it all
        filelike.seek(0)
    except (AttributeError, UnsupportedOperation):
        pass

    hasher = sha1()
    while True:
        data = filelike.read(HASH_READ_SIZE)
        if not data:
            break
        # Use `force_bytes` to account for files opened as text
        hasher.update(force_bytes(data))

    if hasattr(filelike, "seek"):
        # Reset the file handler to where it was before
        filelike.seek(file_pos)

    return hasher.hexdigest()