Opened 70 minutes ago

Last modified 68 minutes ago

#37444 assigned Bug

Prevent overly broad destination permissions during file_move_safe() fallback copying

Reported by: Sarah Boyce Owned by: Sarah Boyce
Component: File uploads/storage Version: 6.1
Severity: Normal Keywords: not-security
Cc: Triage Stage: Unreviewed
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

When django.core.files.move.file_move_safe() cannot use os.rename(), for example when moving across filesystems, it falls back to copying the file contents. See ​https://github.com/django/django/blob/3d3d943b7442f1f5278ed876331a06047fa96ea8/django/core/files/move.py#L52

The fallback creates the destination using os.open() with O_CREAT but without an explicit mode. This defaults to 0o777, subject to the process umask. The source permissions are applied using copystat() only after copying completes.

As a consequence, a source file with permissions of 0o600 (owner read/write only) can have a destination with permissions of 0o755 (owner read/write/execute; group and others read/execute) during copying when the process umask is 0o022 (removing write permission for group and others).

The destination should be created with permissions that do not expose the source contents more broadly during copying.

See test for behavior demonstration:

  • tests/files/tests.py

    a b  
    11import errno
    22import gzip
    33import os
     4import stat
    45import struct
    56import tempfile
    67import unittest
    … … class FileMoveSafeTests(unittest.TestCase):  
    520521
    521522        self.assertEqual(content, b"content")
    522523
     524    @unittest.skipUnless(os.name == "posix", "Requires POSIX permissions.")
     525    def test_file_move_preserves_private_permissions_during_copy(self):
     526        real_write = os.write
     527
     528        def recording_write(fd, content):
     529            written = real_write(fd, content)
     530            if written:
     531                observed_modes.append(stat.S_IMODE(os.fstat(fd).st_mode))
     532            return written
     533
     534        # umask() returns the current mask, so it'll be restored on cleanup.
     535        self.addCleanup(os.umask, os.umask(0o000))
     536        destination_creation_mode = 0o600
     537
     538        for source_mode in (0o600, 0o640):
     539            with (
     540                self.subTest(source_mode=source_mode),
     541                tempfile.TemporaryDirectory() as temp_dir,
     542            ):
     543                observed_modes = []
     544                source = Path(temp_dir) / "source"
     545                destination = Path(temp_dir) / "destination"
     546                source.write_bytes(b"private content")
     547                source.chmod(source_mode)
     548
     549                with (
     550                    mock.patch(
     551                        "django.core.files.move.os.rename",
     552                        side_effect=OSError(errno.EXDEV),
     553                    ),
     554                    mock.patch(
     555                        "django.core.files.move.os.write",
     556                        side_effect=recording_write,
     557                    ),
     558                ):
     559                    file_move_safe(source, destination)
     560
     561                # Keep contents private until copying has finished.
     562                self.assertEqual(set(observed_modes), {destination_creation_mode})
     563                self.assertEqual(stat.S_IMODE(destination.stat().st_mode), source_mode)
     564                self.assertEqual(destination.read_bytes(), b"private content")
     565                self.assertFalse(source.exists())
     566
    523567
    524568class SpooledTempTests(unittest.TestCase):

This was reported privately to the Django security team by "Squirt1e, Atum, N0b0dy, Gh0u1L5 Please credit Squirt1e, Atum, N0b0dy, and Gh0u1L5", which assessed it as a bug suitable for public hardening.

Change History (1)

comment:1 by Sarah Boyce, 68 minutes ago

Has patch: set
Note: See TracTickets for help on using tickets.
Back to Top