CVE-2023-28837 (Python, published 2023-04-03T17:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: 4.9)
Weakness: CWE-400 \u2014 Uncontrolled Resource Consumption
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.
Repository: https://github.com/wagtail/wagtail
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 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.


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()