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 1 1 import errno 2 2 import gzip 3 3 import os 4 import stat 4 5 import struct 5 6 import tempfile 6 7 import unittest … … class FileMoveSafeTests(unittest.TestCase): 520 521 521 522 self.assertEqual(content, b"content") 522 523 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 523 567 524 568 class 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.