CVE-2022-24840 (Python, published 2022-06-09T04:15Z)
Description: value: django-s3file is a lightweight file upload input for Django and Amazon S3 . In versions prior to 5.5.1 it was possible to traverse the entire AWS S3 bucket and in most cases to access or delete files. If the `AWS_LOCATION` setting was set, traversal was limited to that location only. The issue was discovered by the maintainer. There were no reports of the vulnerability being known to or exploited by a third party, prior to the release of the patch. The vulnerability has been fixed in version 5.5.1 and above. There is no feasible workaround. We must urge all users to immediately updated to a patched version.
Severity: HIGH (CVSS v3 base score: 9.8)
Weakness: CWE-22 \u2014 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
Repository: https://github.com/codingjoe/django-s3file
Files changed:
s3file/forms.py
s3file/middleware.py
s3file/static/s3file/js/s3file.js
s3file/views.py
tests/conftest.py
tests/test_forms.py
tests/test_middleware.py
Commit message: Fix CVE-XXXX-XXXX -- Fix Path Traversal security vulnerability


Vulnerable code:
    upload_path = str(
        getattr(settings, "S3FILE_UPLOAD_PATH", pathlib.PurePosixPath("tmp", "s3file"))
    upload_path = safe_join(str(storage.location), upload_path)
from s3file.storages import local_dev, storage
            request.FILES.setlist(field_name, list(self.get_files_from_storage(paths)))
    def get_files_from_storage(paths):
                location = storage.aws_location
            except AttributeError:
                location = storage.location
                f = storage.open(str(path.relative_to(location)))
import os
def upload_file(request):
    path = tempfile.mkdtemp()
    file_name = os.path.join(path, "%s.txt" % request.node.name)
    with open(file_name, "w") as f:
    return file_name
def another_upload_file(request):
    path = tempfile.mkdtemp()
    file_name = os.path.join(path, "another_%s.txt" % request.node.name)
    with open(file_name, "w") as f:
    return file_name
def yet_another_upload_file(request):
    path = tempfile.mkdtemp()
    file_name = os.path.join(path, "yet_another_%s.txt" % request.node.name)
    with open(file_name, "w") as f:
    return file_name
    @pytest.fixture
    def freeze(self, monkeypatch):
        """Freeze datetime and UUID."""
        monkeypatch.setattr(
            "s3file.forms.S3FileInputMixin.upload_folder",
            os.path.join(storage.aws_location, "tmp"),
        )

    def test_value_from_datadict(self, client, upload_file):
        print(storage.location)
            uploaded_file = storage.save("test.jpg", f)
                "file": json.dumps([uploaded_file]),
                "s3file": '["file"]',
    def test_build_attr(self):
    def test_get_conditions(self, freeze):
                ["starts-with", "$key", "custom/location/tmp"],
    def test_file_insert(self, request, driver, live_server, upload_file, freeze):
        assert storage.exists("tmp/%s.txt" % request.node.name)
    def test_file_insert_submit_value(self, driver, live_server, upload_file, freeze):
    def test_progress(self, driver, live_server, upload_file, freeze):
        freeze,
        file_input.send_keys(" \n ".join([upload_file, another_upload_file]))
        file_input.send_keys(yet_another_upload_file)
    def test_get_files_from_storage(self):
            [os.path.join(storage.aws_location, name)]
    def test_process_request(self, rf):
    def test_process_request__multiple_files(self, rf):
    def test_process_request__no_location(self, rf, settings):
            "/", data={"file": "tmp/s3file/s3_file.txt", "s3file": "file"}
    def test_process_request__no_file(self, rf, caplog):
        request = rf.post("/", data={"file": "does_not_exist.txt", "s3file": "file"})
        assert "File not found: does_not_exist.txt" in caplog.text

Fixed code:
from django.core import signing
    upload_path = safe_join(
        str(storage.aws_location),
        str(
            getattr(
                settings, "S3FILE_UPLOAD_PATH", pathlib.PurePosixPath("tmp", "s3file")
            )
        ),
        signer = signing.Signer(
            salt=f"{S3FileInputMixin.__module__}.{S3FileInputMixin.__name__}"
        )
        print(self.upload_folder)
        defaults["data-s3f-signature"] = signer.signature(self.upload_folder)
from django.core import signing
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
from django.utils.crypto import constant_time_compare
from .forms import S3FileInputMixin
from .storages import local_dev, storage

            if paths:
                try:
                    signature = request.POST[f"{field_name}-s3f-signature"]
                except KeyError:
                    raise PermissionDenied("No signature provided.")
                try:
                    request.FILES.setlist(
                        field_name, list(self.get_files_from_storage(paths, signature))
                    )
                except SuspiciousFileOperation as e:
                    raise PermissionDenied("Illegal file name!") from e
    def get_files_from_storage(paths, signature):
        try:
            location = storage.aws_location
        except AttributeError:
            location = storage.location
        signer = signing.Signer(
            salt=f"{S3FileInputMixin.__module__}.{S3FileInputMixin.__name__}"
        )
            print(path)
            print(signer.signature(path.parent), signature)
            if not constant_time_compare(signer.signature(path.parent), signature):
                raise PermissionDenied("Illegal signature!")
                relative_path = str(path.relative_to(location))
            except ValueError as e:
                raise SuspiciousFileOperation(
                    f"Path is not inside the designated upload location: {path}"
                ) from e

                f = storage.open(relative_path)
        var hiddenSignatureInput = document.createElement('input')
        hiddenSignatureInput.type = 'hidden'
        hiddenSignatureInput.name = name + '-s3f-signature'
        console.log(fileInput.dataset.s3fSignature)
        hiddenSignatureInput.value = fileInput.dataset.s3fSignature
        form.appendChild(hiddenSignatureInput)
from pathlib import Path
from pathlib import Path
from s3file.storages import storage

def freeze_upload_folder(monkeypatch):
    """Freeze datetime and UUID."""
    upload_folder = Path(storage.aws_location) / "tmp" / "s3file"
    monkeypatch.setattr(
        "s3file.forms.S3FileInputMixin.upload_folder",
        str(upload_folder),
    )
    return upload_folder


@pytest.fixture
def upload_file(request, freeze_upload_folder):
    path = Path(tempfile.mkdtemp()) / freeze_upload_folder / f"{request.node.name}.txt"
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w") as f:
    return str(path.absolute())
def another_upload_file(request, freeze_upload_folder):
    path = (
        Path(tempfile.mkdtemp())
        / freeze_upload_folder
        / f"another_{request.node.name}.txt"
    )
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w") as f:
    return str(path.absolute())
def yet_another_upload_file(request, freeze_upload_folder):
    path = (
        Path(tempfile.mkdtemp())
        / freeze_upload_folder
        / f"yet_another_{request.node.name}.txt"
    )
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w") as f:
    return str(path.absolute())
    def test_value_from_datadict(self, freeze_upload_folder, client, upload_file):
            uploaded_file = storage.save(freeze_upload_folder / "test.jpg", f)
                "file": f"custom/location/{uploaded_file}",
                "file-s3f-signature": "m94qBxBsnMIuIICiY133kX18KkllSPMVbhGAdAwNn1A",
                "s3file": "file",
    def test_build_attr(self, freeze_upload_folder):
            "data-s3f-signature",
        assert (
            ClearableFileInput().build_attrs({})["data-s3f-signature"]
            == "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc"
        )
    def test_get_conditions(self, freeze_upload_folder):
                ["starts-with", "$key", "custom/location/tmp/s3file"],
    def test_file_insert(
        self, request, driver, live_server, upload_file, freeze_upload_folder
    ):
        assert storage.exists("tmp/s3file/%s.txt" % request.node.name)
    def test_file_insert_submit_value(
        self, driver, live_server, upload_file, freeze_upload_folder
    ):
    def test_progress(self, driver, live_server, upload_file, freeze_upload_folder):
        freeze_upload_folder,
        file_input.send_keys(
            " \n ".join(
                [
                    str(freeze_upload_folder / upload_file),
                    str(freeze_upload_folder / another_upload_file),
                ]
            )
        )
        file_input.send_keys(str(freeze_upload_folder / yet_another_upload_file))
import pytest
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
    def test_get_files_from_storage(self, freeze_upload_folder):
            [os.path.join(storage.aws_location, name)],
            "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc",
    def test_process_request(self, freeze_upload_folder, rf):
                "file-s3f-signature": "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc",
    def test_process_request__location_escape(self, freeze_upload_folder, rf):
        storage.save("secrets/passwords.txt", ContentFile(b"keep this secret"))
        request = rf.post(
            "/",
            data={
                "file": "custom/location/secrets/passwords.txt",
                "s3file": "file",
                "file-s3f-signature": "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc",
            },
        )
        with pytest.raises(PermissionDenied) as e:
            S3FileMiddleware(lambda x: None)(request)
        assert "Illegal signature!" in str(e.value)

    def test_process_request__multiple_files(self, freeze_upload_folder, rf):
                "file-s3f-signature": "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc",
                "other_file-s3f-signature": "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc",
    def test_process_request__no_location(self, freeze_upload_folder, rf, settings):
            "/",
            data={
                "file": f"tmp/s3file/s3_file.txt",
                "s3file": "file",
                "file-s3f-signature": "scjzm3N8njBQIVSGEhOchtM0TkGyb2U6OXGLVlRUZhY",
            },
    def test_process_request__no_file(self, freeze_upload_folder, rf, caplog):
        request = rf.post(
            "/",
            data={
                "file": "custom/location/tmp/s3file/does_not_exist.txt",
                "s3file": "file",
                "file-s3f-signature": "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc",
            },
        )
        assert (
            "File not found: custom/location/tmp/s3file/does_not_exist.txt"
            in caplog.text
        )

    def test_process_request__no_signature(self, rf, caplog):
        request = rf.post(
            "/", data={"file": "tmp/s3file/does_not_exist.txt", "s3file": "file"}
        )
        with pytest.raises(PermissionDenied) as e:
            S3FileMiddleware(lambda x: None)(request)

    def test_process_request__wrong_signature(self, rf, caplog):
        request = rf.post(
            "/",
            data={
                "file": "tmp/s3file/does_not_exist.txt",
                "s3file": "file",
                "file-s3f-signature": "fake",
            },
        )
        with pytest.raises(PermissionDenied) as e:
            S3FileMiddleware(lambda x: None)(request)