Directory traversal / path traversal via archive.extract()
Description
The commit fixes a partial directory-traversal vulnerability in django.utils.archive.extract() (CVE-2025-59682). Previously, the code performed a naive path check using filename.startswith(target_path) after joining the archive entry name with the target directory. This could be bypassed when an archive member path started with the target directory name but resolved outside the target (e.g., an absolute path inside the archive like /tmp/evil.txt or a path such as /tmp/baseevil.txt). The patch replaces the check with os.path.commonpath([target_path, filename]) and raises SuspiciousOperation if the computed path is not within the target directory, effectively preventing extraction outside the intended directory. The change addresses partial directory traversal during archive extraction and is reflected in releases for affected branches (4.2, 5.1, 5.2).
Proof of Concept
import zipfile, os, tempfile
from django.utils import archive
from django.core.exceptions import SuspiciousOperation
def run_poc():
# Demonstrates exploitation attempt using an absolute path inside the archive
with tempfile.TemporaryDirectory() as target_dir:
zip_path = os.path.join(target_dir, 'attack.zip')
# Create a tiny archive that contains an absolute path entry outside the target dir
with zipfile.ZipFile(zip_path, 'w') as zf:
zf.writestr('/tmp/evil_outside.txt', 'evil')
try:
archive.extract(zip_path, target_dir)
except SuspiciousOperation:
print('Exploit blocked by fix (SuspiciousOperation raised)')
else:
print('Archive.extract wrote outside the target directory (vulnerability existed pre-fix)')
if __name__ == '__main__':
run_poc()
Commit Details
Author: Sarah Boyce
Date: 2025-09-16 15:13 UTC
Message:
Fixed CVE-2025-59682 -- Fixed potential partial directory-traversal via archive.extract().
Thanks stackered for the report.
Follow up to 05413afa8c18cdb978fcdf470e09f7a12b234a23.
Triage Assessment
Vulnerability Type: Directory Traversal
Confidence: HIGH
Reasoning:
The patch adds validation to archive extraction to prevent partial directory traversal by ensuring extracted paths stay within the target directory (using os.path.commonpath) and raises SuspiciousOperation on invalid paths. This directly addresses CVE-2025-59682.
Verification Assessment
Vulnerability Type: Directory traversal / path traversal via archive.extract()
Confidence: HIGH
Affected Versions: 4.2.x: <= 4.2.24; 5.1.x: <= 5.1.12; 5.2.x: <= 5.2.6
Code Diff
diff --git a/django/utils/archive.py b/django/utils/archive.py
index 4042e89af943..e9c0ce7cb435 100644
--- a/django/utils/archive.py
+++ b/django/utils/archive.py
@@ -145,7 +145,11 @@ def has_leading_dir(self, paths):
def target_filename(self, to_path, name):
target_path = os.path.abspath(to_path)
filename = os.path.abspath(os.path.join(target_path, name))
- if not filename.startswith(target_path):
+ try:
+ if os.path.commonpath([target_path, filename]) != target_path:
+ raise SuspiciousOperation("Archive contains invalid path: '%s'" % name)
+ except ValueError:
+ # Different drives on Windows raises ValueError.
raise SuspiciousOperation("Archive contains invalid path: '%s'" % name)
return filename
diff --git a/docs/releases/4.2.25.txt b/docs/releases/4.2.25.txt
index 5412777055bf..7ba23c01323a 100644
--- a/docs/releases/4.2.25.txt
+++ b/docs/releases/4.2.25.txt
@@ -15,3 +15,11 @@ CVE-2025-59681: Potential SQL injection in ``QuerySet.annotate()``, ``alias()``,
to SQL injection in column aliases, using a suitably crafted dictionary, with
dictionary expansion, as the ``**kwargs`` passed to these methods (follow up to
:cve:`2022-28346`).
+
+CVE-2025-59682: Potential partial directory-traversal via ``archive.extract()``
+===============================================================================
+
+The ``django.utils.archive.extract()`` function, used by
+:option:`startapp --template` and :option:`startproject --template`, allowed
+partial directory-traversal via an archive with file paths sharing a common
+prefix with the target directory (follow up to :cve:`2021-3281`).
diff --git a/docs/releases/5.1.13.txt b/docs/releases/5.1.13.txt
index 96b81c0102e8..7b9b5c8d3938 100644
--- a/docs/releases/5.1.13.txt
+++ b/docs/releases/5.1.13.txt
@@ -15,3 +15,11 @@ CVE-2025-59681: Potential SQL injection in ``QuerySet.annotate()``, ``alias()``,
to SQL injection in column aliases, using a suitably crafted dictionary, with
dictionary expansion, as the ``**kwargs`` passed to these methods (follow up to
:cve:`2022-28346`).
+
+CVE-2025-59682: Potential partial directory-traversal via ``archive.extract()``
+===============================================================================
+
+The ``django.utils.archive.extract()`` function, used by
+:option:`startapp --template` and :option:`startproject --template`, allowed
+partial directory-traversal via an archive with file paths sharing a common
+prefix with the target directory (follow up to :cve:`2021-3281`).
diff --git a/docs/releases/5.2.7.txt b/docs/releases/5.2.7.txt
index 05d03a991e90..b8c27d1de244 100644
--- a/docs/releases/5.2.7.txt
+++ b/docs/releases/5.2.7.txt
@@ -17,6 +17,14 @@ to SQL injection in column aliases, using a suitably crafted dictionary, with
dictionary expansion, as the ``**kwargs`` passed to these methods (follow up to
:cve:`2022-28346`).
+CVE-2025-59682: Potential partial directory-traversal via ``archive.extract()``
+===============================================================================
+
+The ``django.utils.archive.extract()`` function, used by
+:option:`startapp --template` and :option:`startproject --template`, allowed
+partial directory-traversal via an archive with file paths sharing a common
+prefix with the target directory (follow up to :cve:`2021-3281`).
+
Bugfixes
========
diff --git a/tests/utils_tests/test_archive.py b/tests/utils_tests/test_archive.py
index 89a45bc072ad..4d365e4d9825 100644
--- a/tests/utils_tests/test_archive.py
+++ b/tests/utils_tests/test_archive.py
@@ -3,6 +3,7 @@
import sys
import tempfile
import unittest
+import zipfile
from django.core.exceptions import SuspiciousOperation
from django.test import SimpleTestCase
@@ -94,3 +95,21 @@ def test_extract_function_traversal(self):
with self.subTest(entry), tempfile.TemporaryDirectory() as tmpdir:
with self.assertRaisesMessage(SuspiciousOperation, msg % invalid_path):
archive.extract(os.path.join(archives_dir, entry), tmpdir)
+
+ def test_extract_function_traversal_startswith(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ base = os.path.abspath(tmpdir)
+ tarfile_handle = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
+ tar_path = tarfile_handle.name
+ tarfile_handle.close()
+ self.addCleanup(os.remove, tar_path)
+
+ malicious_member = os.path.join(base + "abc", "evil.txt")
+ with zipfile.ZipFile(tar_path, "w") as zf:
+ zf.writestr(malicious_member, "evil\n")
+ zf.writestr("test.txt", "data\n")
+
+ with self.assertRaisesMessage(
+ SuspiciousOperation, "Archive contains invalid path"
+ ):
+ archive.extract(tar_path, base)