From fd3215dec5d50aa1f09cb1f8eba193524e7379f3 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak <felisiak.mariusz@gmail.com>
Date: Thu, 25 May 2023 14:49:15 +0000
Subject: [PATCH] Fixed CVE-2023-31047, Fixed #31710

-- Prevented potential bypass of validation when uploading multiple files using one form field.

Thanks Moataz Al-Sharida and nawaik for reports.

Co-authored-by: Shai Berger <shai@platonix.com>
Co-authored-by: nessita <124304+nessita@users.noreply.github.com>

CVE: CVE-2023-31047

Upstream-Status: Backport [https://github.com/django/django/commit/fb4c55d9ec4bb812a7fb91fa20510d91645e411b]

Signed-off-by: Narpat Mali <narpat.mali@windriver.com>
---
 django/forms/widgets.py                       | 26 ++++++-
 docs/releases/2.2.28.txt                      | 18 +++++
 docs/topics/http/file-uploads.txt             | 65 ++++++++++++++++--
 .../forms_tests/field_tests/test_filefield.py | 68 ++++++++++++++++++-
 .../widget_tests/test_clearablefileinput.py   |  5 ++
 .../widget_tests/test_fileinput.py            | 44 ++++++++++++
 6 files changed, 218 insertions(+), 8 deletions(-)

diff --git a/django/forms/widgets.py b/django/forms/widgets.py
index e37036c..d0cc131 100644
--- a/django/forms/widgets.py
+++ b/django/forms/widgets.py
@@ -372,17 +372,41 @@ class MultipleHiddenInput(HiddenInput):


 class FileInput(Input):
+    allow_multiple_selected = False
     input_type = 'file'
     needs_multipart_form = True
     template_name = 'django/forms/widgets/file.html'

+    def __init__(self, attrs=None):
+        if (
+            attrs is not None
+            and not self.allow_multiple_selected
+            and attrs.get("multiple", False)
+        ):
+            raise ValueError(
+                "%s doesn't support uploading multiple files."
+                % self.__class__.__qualname__
+            )
+        if self.allow_multiple_selected:
+            if attrs is None:
+                attrs = {"multiple": True}
+            else:
+                attrs.setdefault("multiple", True)
+        super().__init__(attrs)
+
     def format_value(self, value):
         """File input never renders a value."""
         return

     def value_from_datadict(self, data, files, name):
         "File widgets take data from FILES, not POST"
-        return files.get(name)
+        getter = files.get
+        if self.allow_multiple_selected:
+            try:
+                getter = files.getlist
+            except AttributeError:
+                pass
+        return getter(name)

     def value_omitted_from_data(self, data, files, name):
         return name not in files
diff --git a/docs/releases/2.2.28.txt b/docs/releases/2.2.28.txt
index 43270fc..854c6b0 100644
--- a/docs/releases/2.2.28.txt
+++ b/docs/releases/2.2.28.txt
@@ -20,3 +20,21 @@ CVE-2022-28347: Potential SQL injection via ``QuerySet.explain(**options)`` on P
 :meth:`.QuerySet.explain` method was subject to SQL injection in option names,
 using a suitably crafted dictionary, with dictionary expansion, as the
 ``**options`` argument.
+
+Backporting the CVE-2023-31047 fix on Django 2.2.28.
+
+CVE-2023-31047: Potential bypass of validation when uploading multiple files using one form field
+=================================================================================================
+
+Uploading multiple files using one form field has never been supported by
+:class:`.forms.FileField` or :class:`.forms.ImageField` as only the last
+uploaded file was validated. Unfortunately, :ref:`uploading_multiple_files`
+topic suggested otherwise.
+
+In order to avoid the vulnerability, :class:`~django.forms.ClearableFileInput`
+and :class:`~django.forms.FileInput` form widgets now raise ``ValueError`` when
+the ``multiple`` HTML attribute is set on them. To prevent the exception and
+keep the old behavior, set ``allow_multiple_selected`` to ``True``.
+
+For more details on using the new attribute and handling of multiple files
+through a single field, see :ref:`uploading_multiple_files`.
diff --git a/docs/topics/http/file-uploads.txt b/docs/topics/http/file-uploads.txt
index 21a6f06..c1ffb80 100644
--- a/docs/topics/http/file-uploads.txt
+++ b/docs/topics/http/file-uploads.txt
@@ -127,19 +127,54 @@ field in the model::
             form = UploadFileForm()
         return render(request, 'upload.html', {'form': form})

+.. _uploading_multiple_files:
+
 Uploading multiple files
 ------------------------

-If you want to upload multiple files using one form field, set the ``multiple``
-HTML attribute of field's widget:
+..
+    Tests in tests.forms_tests.field_tests.test_filefield.MultipleFileFieldTest
+    should be updated after any changes in the following snippets.
+
+If you want to upload multiple files using one form field, create a subclass
+of the field's widget and set the ``allow_multiple_selected`` attribute on it
+to ``True``.
+
+In order for such files to be all validated by your form (and have the value of
+the field include them all), you will also have to subclass ``FileField``. See
+below for an example.
+
+.. admonition:: Multiple file field
+
+    Django is likely to have a proper multiple file field support at some point
+    in the future.

 .. code-block:: python
     :caption: forms.py

     from django import forms

+
+    class MultipleFileInput(forms.ClearableFileInput):
+        allow_multiple_selected = True
+
+
+    class MultipleFileField(forms.FileField):
+        def __init__(self, *args, **kwargs):
+            kwargs.setdefault("widget", MultipleFileInput())
+            super().__init__(*args, **kwargs)
+
+        def clean(self, data, initial=None):
+            single_file_clean = super().clean
+            if isinstance(data, (list, tuple)):
+                result = [single_file_clean(d, initial) for d in data]
+            else:
+                result = single_file_clean(data, initial)
+            return result
+
+
     class FileFieldForm(forms.Form):
-        file_field = forms.FileField(widget=forms.ClearableFileInput(attrs={'multiple': True}))
+        file_field = MultipleFileField()

 Then override the ``post`` method of your
 :class:`~django.views.generic.edit.FormView` subclass to handle multiple file
@@ -159,14 +194,32 @@ uploads:
         def post(self, request, *args, **kwargs):
             form_class = self.get_form_class()
             form = self.get_form(form_class)
-            files = request.FILES.getlist('file_field')
             if form.is_valid():
-                for f in files:
-                    ...  # Do something with each file.
                 return self.form_valid(form)
             else:
                 return self.form_invalid(form)

+        def form_valid(self, form):
+            files = form.cleaned_data["file_field"]
+            for f in files:
+                ...  # Do something with each file.
+            return super().form_valid()
+
+.. warning::
+
+   This will allow you to handle multiple files at the form level only. Be
+   aware that you cannot use it to put multiple files on a single model
+   instance (in a single field), for example, even if the custom widget is used
+   with a form field related to a model ``FileField``.
+
+.. backportedfix:: 2.2.28
+
+   In previous versions, there was no support for the ``allow_multiple_selected``
+   class attribute, and users were advised to create the widget with the HTML
+   attribute ``multiple`` set through the ``attrs`` argument. However, this
+   caused validation of the form field to be applied only to the last file
+   submitted, which could have adverse security implications.
+
 Upload Handlers
 ===============

diff --git a/tests/forms_tests/field_tests/test_filefield.py b/tests/forms_tests/field_tests/test_filefield.py
index 3357444..ba559ee 100644
--- a/tests/forms_tests/field_tests/test_filefield.py
+++ b/tests/forms_tests/field_tests/test_filefield.py
@@ -1,7 +1,8 @@
 import pickle

 from django.core.files.uploadedfile import SimpleUploadedFile
-from django.forms import FileField, ValidationError
+from django.core.validators import validate_image_file_extension
+from django.forms import FileField, FileInput, ValidationError
 from django.test import SimpleTestCase


@@ -82,3 +83,68 @@ class FileFieldTest(SimpleTestCase):

     def test_file_picklable(self):
         self.assertIsInstance(pickle.loads(pickle.dumps(FileField())), FileField)
+
+
+class MultipleFileInput(FileInput):
+    allow_multiple_selected = True
+
+
+class MultipleFileField(FileField):
+    def __init__(self, *args, **kwargs):
+        kwargs.setdefault("widget", MultipleFileInput())
+        super().__init__(*args, **kwargs)
+
+    def clean(self, data, initial=None):
+        single_file_clean = super().clean
+        if isinstance(data, (list, tuple)):
+            result = [single_file_clean(d, initial) for d in data]
+        else:
+            result = single_file_clean(data, initial)
+        return result
+
+
+class MultipleFileFieldTest(SimpleTestCase):
+    def test_file_multiple(self):
+        f = MultipleFileField()
+        files = [
+            SimpleUploadedFile("name1", b"Content 1"),
+            SimpleUploadedFile("name2", b"Content 2"),
+        ]
+        self.assertEqual(f.clean(files), files)
+
+    def test_file_multiple_empty(self):
+        f = MultipleFileField()
+        files = [
+            SimpleUploadedFile("empty", b""),
+            SimpleUploadedFile("nonempty", b"Some Content"),
+        ]
+        msg = "'The submitted file is empty.'"
+        with self.assertRaisesMessage(ValidationError, msg):
+            f.clean(files)
+        with self.assertRaisesMessage(ValidationError, msg):
+            f.clean(files[::-1])
+
+    def test_file_multiple_validation(self):
+        f = MultipleFileField(validators=[validate_image_file_extension])
+
+        good_files = [
+            SimpleUploadedFile("image1.jpg", b"fake JPEG"),
+            SimpleUploadedFile("image2.png", b"faux image"),
+            SimpleUploadedFile("image3.bmp", b"fraudulent bitmap"),
+        ]
+        self.assertEqual(f.clean(good_files), good_files)
+
+        evil_files = [
+            SimpleUploadedFile("image1.sh", b"#!/bin/bash -c 'echo pwned!'\n"),
+            SimpleUploadedFile("image2.png", b"faux image"),
+            SimpleUploadedFile("image3.jpg", b"fake JPEG"),
+        ]
+
+        evil_rotations = (
+            evil_files[i:] + evil_files[:i]  # Rotate by i.
+            for i in range(len(evil_files))
+        )
+        msg = "File extension “sh” is not allowed. Allowed extensions are: "
+        for rotated_evil_files in evil_rotations:
+            with self.assertRaisesMessage(ValidationError, msg):
+                f.clean(rotated_evil_files)
diff --git a/tests/forms_tests/widget_tests/test_clearablefileinput.py b/tests/forms_tests/widget_tests/test_clearablefileinput.py
index 2ba376d..8d9e38a 100644
--- a/tests/forms_tests/widget_tests/test_clearablefileinput.py
+++ b/tests/forms_tests/widget_tests/test_clearablefileinput.py
@@ -161,3 +161,8 @@ class ClearableFileInputTest(WidgetTest):
         self.assertIs(widget.value_omitted_from_data({}, {}, 'field'), True)
         self.assertIs(widget.value_omitted_from_data({}, {'field': 'x'}, 'field'), False)
         self.assertIs(widget.value_omitted_from_data({'field-clear': 'y'}, {}, 'field'), False)
+
+    def test_multiple_error(self):
+        msg = "ClearableFileInput doesn't support uploading multiple files."
+        with self.assertRaisesMessage(ValueError, msg):
+            ClearableFileInput(attrs={"multiple": True})
diff --git a/tests/forms_tests/widget_tests/test_fileinput.py b/tests/forms_tests/widget_tests/test_fileinput.py
index bbd7c7f..24daf5d 100644
--- a/tests/forms_tests/widget_tests/test_fileinput.py
+++ b/tests/forms_tests/widget_tests/test_fileinput.py
@@ -1,4 +1,6 @@
+from django.core.files.uploadedfile import SimpleUploadedFile
 from django.forms import FileInput
+from django.utils.datastructures import MultiValueDict

 from .base import WidgetTest

@@ -18,3 +20,45 @@ class FileInputTest(WidgetTest):
     def test_value_omitted_from_data(self):
         self.assertIs(self.widget.value_omitted_from_data({}, {}, 'field'), True)
         self.assertIs(self.widget.value_omitted_from_data({}, {'field': 'value'}, 'field'), False)
+
+    def test_multiple_error(self):
+        msg = "FileInput doesn't support uploading multiple files."
+        with self.assertRaisesMessage(ValueError, msg):
+            FileInput(attrs={"multiple": True})
+
+    def test_value_from_datadict_multiple(self):
+        class MultipleFileInput(FileInput):
+            allow_multiple_selected = True
+
+        file_1 = SimpleUploadedFile("something1.txt", b"content 1")
+        file_2 = SimpleUploadedFile("something2.txt", b"content 2")
+        # Uploading multiple files is allowed.
+        widget = MultipleFileInput(attrs={"multiple": True})
+        value = widget.value_from_datadict(
+            data={"name": "Test name"},
+            files=MultiValueDict({"myfile": [file_1, file_2]}),
+            name="myfile",
+        )
+        self.assertEqual(value, [file_1, file_2])
+        # Uploading multiple files is not allowed.
+        widget = FileInput()
+        value = widget.value_from_datadict(
+            data={"name": "Test name"},
+            files=MultiValueDict({"myfile": [file_1, file_2]}),
+            name="myfile",
+        )
+        self.assertEqual(value, file_2)
+
+    def test_multiple_default(self):
+        class MultipleFileInput(FileInput):
+            allow_multiple_selected = True
+
+        tests = [
+            (None, True),
+            ({"class": "myclass"}, True),
+            ({"multiple": False}, False),
+        ]
+        for attrs, expected in tests:
+            with self.subTest(attrs=attrs):
+                widget = MultipleFileInput(attrs=attrs)
+                self.assertIs(widget.attrs["multiple"], expected)
--
2.40.0
