CVE-2021-31542
Description
In Django 2.2 before 2.2.21, 3.1 before 3.1.9, and 3.2 before 3.2.1, MultiPartParser, UploadedFile, and FieldFile allowed directory traversal via uploaded files with suitably crafted file names.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
In Django 2.2-2.2.20, 3.1-3.1.8, 3.2-3.2.0, crafted filenames allowed directory traversal via MultiPartParser, UploadedFile, and FieldFile.
Vulnerability
In Django versions 2.2 before 2.2.21, 3.1 before 3.1.9, and 3.2 before 3.2.1, the MultiPartParser, UploadedFile, and FieldFile components did not properly sanitize uploaded file names containing path traversal sequences. An attacker could craft a filename with .. to overwrite files outside the intended upload directory. The affected code paths are in django/core/files/uploadedfile.py and django/core/files/storage.py [1][2][3].
Exploitation
An attacker with the ability to upload files to a Django application (e.g., via a form with a FileField or ImageField) could supply a filename containing ../ or similar traversal patterns. No authentication is required if the upload endpoint is public; the attacker only needs network access to the upload functionality. The filename is processed without validation, allowing directory traversal [2][3].
Impact
Successful exploitation allows an attacker to write or overwrite files anywhere on the filesystem that the web server process has write permissions. This could lead to arbitrary code execution if, for example, a malicious file is written to a directory from which it can be executed (e.g., static files or Python modules). The severity is high due to potential for remote code execution [1][4].
Mitigation
The fix was released in Django 2.2.21, 3.1.9, and 3.2.1. The commits add pathlib.PurePath checks for .. parts and call validate_file_name to sanitize filenames [2][3]. Users should upgrade immediately. No workarounds are provided; upgrading is the only mitigation [1][4].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
DjangoPyPI | >= 2.2, < 2.2.21 | 2.2.21 |
DjangoPyPI | >= 3.0, < 3.1.9 | 3.1.9 |
DjangoPyPI | >= 3.2, < 3.2.1 | 3.2.1 |
Affected products
164- Django/Djangodescription
- osv-coords163 versionspkg:bitnami/djangopkg:pypi/djangopkg:rpm/suse/ardana-cobbler&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/ardana-cobbler&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/ardana-neutron&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/ardana-swift&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/cassandra&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/cassandra&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/cassandra&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/cassandra&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/cassandra&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/crowbar-core&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/crowbar-openstack&distro=SUSE%20OpenStack%20Cloud%207pkg:rpm/suse/crowbar-openstack&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/crowbar-openstack&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/documentation-hpe-helion-openstack-installation&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/documentation-hpe-helion-openstack-operations&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/documentation-hpe-helion-openstack-opsconsole&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/documentation-hpe-helion-openstack-planning&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/documentation-hpe-helion-openstack-security&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/documentation-hpe-helion-openstack-user&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/documentation-suse-openstack-cloud-deployment&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/documentation-suse-openstack-cloud-installation&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/documentation-suse-openstack-cloud-operations&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/documentation-suse-openstack-cloud-opsconsole&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/documentation-suse-openstack-cloud-planning&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/documentation-suse-openstack-cloud-security&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/documentation-suse-openstack-cloud-supplement&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/documentation-suse-openstack-cloud-supplement&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/documentation-suse-openstack-cloud-upstream-admin&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/documentation-suse-openstack-cloud-upstream-admin&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/documentation-suse-openstack-cloud-upstream-user&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/documentation-suse-openstack-cloud-upstream-user&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/documentation-suse-openstack-cloud-user&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/grafana&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/grafana&distro=SUSE%20OpenStack%20Cloud%207pkg:rpm/suse/grafana&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/grafana&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/grafana&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/grafana&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/kibana&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/kibana&distro=SUSE%20OpenStack%20Cloud%207pkg:rpm/suse/kibana&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/kibana&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/kibana&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/kibana&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/monasca-installer&distro=SUSE%20OpenStack%20Cloud%207pkg:rpm/suse/openstack-dashboard&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/openstack-dashboard&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/openstack-heat-templates&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/openstack-heat-templates&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/openstack-heat-templates&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/openstack-ironic&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/openstack-ironic&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/openstack-monasca-installer&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/openstack-monasca-installer&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/openstack-monasca-installer&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/openstack-neutron&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/openstack-neutron&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/openstack-neutron-gbp&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/openstack-neutron-gbp&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/openstack-nova&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/openstack-nova&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/openstack-nova&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/openstack-nova&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/openstack-nova&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/openstack-nova-doc&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/openstack-nova-doc&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/openstack-nova-doc&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/python-Django1&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/python-Django1&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/python-Django&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/python-Django&distro=SUSE%20OpenStack%20Cloud%207pkg:rpm/suse/python-Django&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/python-Django&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/python-elementpath&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/python-elementpath&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/python-elementpath&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/python-elementpath&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/python-elementpath&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/python-eventlet&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/python-eventlet&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/python-eventlet&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/python-py&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/python-py&distro=SUSE%20OpenStack%20Cloud%207pkg:rpm/suse/python-py&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/python-py&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/python-py&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/python-py&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/python-pysaml2&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/python-pysaml2&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/python-pysaml2&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/python-pysaml2&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/python-pysaml2&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/python-xmlschema&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/python-xmlschema&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/python-xmlschema&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/python-xmlschema&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/python-xmlschema&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/rubygem-activerecord-session_store&distro=SUSE%20OpenStack%20Cloud%207pkg:rpm/suse/rubygem-activerecord-session_store&distro=SUSE%20OpenStack%20Cloud%20Crowbar%208pkg:rpm/suse/rubygem-activerecord-session_store&distro=SUSE%20OpenStack%20Cloud%20Crowbar%209pkg:rpm/suse/venv-openstack-aodh&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-aodh&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-barbican&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-barbican&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-barbican&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-ceilometer&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-ceilometer&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-cinder&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-cinder&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-cinder&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-designate&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-designate&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-designate&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-freezer&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-freezer&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-glance&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-glance&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-glance&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-heat&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-heat&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-heat&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-horizon&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-horizon&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-horizon-hpe&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-ironic&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-ironic&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-ironic&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-keystone&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-keystone&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-keystone&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-magnum&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-magnum&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-magnum&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-manila&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-manila&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-manila&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-monasca-ceilometer&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-monasca-ceilometer&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-monasca-ceilometer&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-monasca&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-monasca&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-monasca&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-murano&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-murano&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-neutron&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-neutron&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-neutron&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-nova&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-nova&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-nova&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-octavia&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-octavia&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-octavia&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-sahara&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-sahara&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-sahara&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-swift&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-swift&distro=SUSE%20OpenStack%20Cloud%208pkg:rpm/suse/venv-openstack-swift&distro=SUSE%20OpenStack%20Cloud%209pkg:rpm/suse/venv-openstack-trove&distro=HPE%20Helion%20OpenStack%208pkg:rpm/suse/venv-openstack-trove&distro=SUSE%20OpenStack%20Cloud%208
>= 2.2.0, < 2.2.21+ 162 more
- (no CPE)range: >= 2.2.0, < 2.2.21
- (no CPE)range: >= 2.2, < 2.2.21
- (no CPE)range: < 8.0+git.1614096566.e8c2b27-3.44.3
- (no CPE)range: < 8.0+git.1614096566.e8c2b27-3.44.3
- (no CPE)range: < 9.0+git.1615223676.777f0b3-3.25.2
- (no CPE)range: < 9.0+git.1618235096.90974ed-3.10.2
- (no CPE)range: < 3.11.10-5.3.5
- (no CPE)range: < 3.11.10-5.3.5
- (no CPE)range: < 3.11.10-3.3.3
- (no CPE)range: < 3.11.10-5.3.5
- (no CPE)range: < 3.11.10-3.3.3
- (no CPE)range: < 5.0+git.1622489449.a8e60e238-3.50.4
- (no CPE)range: < 4.0+git.1616146720.44daffca0-9.81.2
- (no CPE)range: < 5.0+git.1616001417.67fd9c2a1-4.52.5
- (no CPE)range: < 6.0+git.1616146717.a89ae0f4e-3.34.4
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 8.20210512-1.32.5
- (no CPE)range: < 6.7.4-4.18.2
- (no CPE)range: < 6.7.4-1.24.2
- (no CPE)range: < 6.7.4-4.18.2
- (no CPE)range: < 6.7.4-3.23.2
- (no CPE)range: < 6.7.4-4.18.2
- (no CPE)range: < 6.7.4-3.23.2
- (no CPE)range: < 4.6.6-3.9.2
- (no CPE)range: < 4.6.6-9.2
- (no CPE)range: < 4.6.6-3.9.2
- (no CPE)range: < 4.6.6-4.9.2
- (no CPE)range: < 4.6.6-3.9.2
- (no CPE)range: < 4.6.6-4.9.2
- (no CPE)range: < 20180608_12.47-16.2
- (no CPE)range: < 14.1.1~dev11-3.24.6
- (no CPE)range: < 14.1.1~dev11-3.24.6
- (no CPE)range: < 0.0.0+git.1623056900.7917e18-3.21.3
- (no CPE)range: < 0.0.0+git.1623056900.7917e18-3.21.3
- (no CPE)range: < 0.0.0+git.1623056900.7917e18-3.21.3
- (no CPE)range: < 11.1.5~dev17-3.25.5
- (no CPE)range: < 11.1.5~dev17-3.25.5
- (no CPE)range: < 20190923_16.32-3.18.2
- (no CPE)range: < 20190923_16.32-3.18.2
- (no CPE)range: < 20190923_16.32-3.18.2
- (no CPE)range: < 13.0.8~dev164-3.37.4
- (no CPE)range: < 13.0.8~dev164-3.37.4
- (no CPE)range: < 12.0.1~dev29-3.25.3
- (no CPE)range: < 12.0.1~dev29-3.25.3
- (no CPE)range: < 16.1.9~dev92-3.48.5
- (no CPE)range: < 16.1.9~dev92-3.48.5
- (no CPE)range: < 18.3.1~dev82-3.37.6
- (no CPE)range: < 16.1.9~dev92-3.48.5
- (no CPE)range: < 18.3.1~dev82-3.37.6
- (no CPE)range: < 16.1.9~dev92-3.48.5
- (no CPE)range: < 16.1.9~dev92-3.48.5
- (no CPE)range: < 16.1.9~dev92-3.48.5
- (no CPE)range: < 1.11.29-3.25.1
- (no CPE)range: < 1.11.29-3.25.1
- (no CPE)range: < 1.11.29-3.25.3
- (no CPE)range: < 1.8.19-3.29.1
- (no CPE)range: < 1.11.29-3.25.3
- (no CPE)range: < 1.11.29-3.25.3
- (no CPE)range: < 1.3.1-1.3.2
- (no CPE)range: < 1.3.1-1.3.2
- (no CPE)range: < 1.3.1-1.3.2
- (no CPE)range: < 1.3.1-1.3.2
- (no CPE)range: < 1.3.1-1.3.2
- (no CPE)range: < 0.20.0-6.3.3
- (no CPE)range: < 0.20.0-6.3.3
- (no CPE)range: < 0.20.0-6.3.3
- (no CPE)range: < 1.4.34-3.3.3
- (no CPE)range: < 1.8.1-11.16.2
- (no CPE)range: < 1.4.34-3.3.3
- (no CPE)range: < 1.5.4-3.3.2
- (no CPE)range: < 1.4.34-3.3.3
- (no CPE)range: < 1.5.4-3.3.2
- (no CPE)range: < 4.0.2-5.9.2
- (no CPE)range: < 4.0.2-5.9.2
- (no CPE)range: < 4.5.0-4.6.2
- (no CPE)range: < 4.0.2-5.9.2
- (no CPE)range: < 4.5.0-4.6.2
- (no CPE)range: < 1.0.18-1.3.3
- (no CPE)range: < 1.0.18-1.3.3
- (no CPE)range: < 1.0.18-1.3.2
- (no CPE)range: < 1.0.18-1.3.3
- (no CPE)range: < 1.0.18-1.3.2
- (no CPE)range: < 0.1.2-3.4.2
- (no CPE)range: < 0.1.2-3.3.2
- (no CPE)range: < 0.1.2-4.3.2
- (no CPE)range: < 5.1.1~dev7-12.32.3
- (no CPE)range: < 5.1.1~dev7-12.32.3
- (no CPE)range: < 5.0.2~dev3-12.33.3
- (no CPE)range: < 5.0.2~dev3-12.33.3
- (no CPE)range: < 7.0.1~dev24-3.23.1
- (no CPE)range: < 9.0.8~dev7-12.30.3
- (no CPE)range: < 9.0.8~dev7-12.30.3
- (no CPE)range: < 11.2.3~dev29-14.34.2
- (no CPE)range: < 11.2.3~dev29-14.34.2
- (no CPE)range: < 13.0.10~dev20-3.26.1
- (no CPE)range: < 5.0.3~dev7-12.31.3
- (no CPE)range: < 5.0.3~dev7-12.31.3
- (no CPE)range: < 7.0.2~dev2-3.23.1
- (no CPE)range: < 5.0.0.0~xrc2~dev2-10.28.3
- (no CPE)range: < 5.0.0.0~xrc2~dev2-10.28.3
- (no CPE)range: < 15.0.3~dev3-12.31.3
- (no CPE)range: < 15.0.3~dev3-12.31.3
- (no CPE)range: < 17.0.1~dev30-3.21.1
- (no CPE)range: < 9.0.8~dev22-12.33.2
- (no CPE)range: < 9.0.8~dev22-12.33.2
- (no CPE)range: < 11.0.4~dev4-3.23.1
- (no CPE)range: < 12.0.5~dev6-14.36.6
- (no CPE)range: < 14.1.1~dev11-4.27.3
- (no CPE)range: < 12.0.5~dev6-14.36.3
- (no CPE)range: < 9.1.8~dev8-12.33.3
- (no CPE)range: < 9.1.8~dev8-12.33.3
- (no CPE)range: < 11.1.5~dev17-4.21.2
- (no CPE)range: < 12.0.4~dev11-11.35.3
- (no CPE)range: < 12.0.4~dev11-11.35.3
- (no CPE)range: < 14.2.1~dev4-3.24.3
- (no CPE)range: < 5.0.2_5.0.2_5.0.2~dev31-11.32.2
- (no CPE)range: < 5.0.2_5.0.2_5.0.2~dev31-11.32.2
- (no CPE)range: < 7.2.1~dev1-4.23.1
- (no CPE)range: < 5.1.1~dev5-12.37.3
- (no CPE)range: < 5.1.1~dev5-12.37.3
- (no CPE)range: < 7.4.2~dev60-3.29.1
- (no CPE)range: < 1.5.1_1.5.1_1.5.1~dev3-8.28.3
- (no CPE)range: < 1.5.1_1.5.1_1.5.1~dev3-8.28.3
- (no CPE)range: < 1.8.2~dev3-3.23.2
- (no CPE)range: < 2.2.2~dev1-11.28.3
- (no CPE)range: < 2.2.2~dev1-11.28.3
- (no CPE)range: < 2.7.1~dev10-3.21.1
- (no CPE)range: < 4.0.2~dev2-12.28.3
- (no CPE)range: < 4.0.2~dev2-12.28.3
- (no CPE)range: < 11.0.9~dev69-13.38.3
- (no CPE)range: < 11.0.9~dev69-13.38.3
- (no CPE)range: < 13.0.8~dev164-6.27.3
- (no CPE)range: < 16.1.9~dev92-11.36.3
- (no CPE)range: < 16.1.9~dev92-11.36.3
- (no CPE)range: < 18.3.1~dev82-3.27.3
- (no CPE)range: < 1.0.6~dev3-12.33.3
- (no CPE)range: < 1.0.6~dev3-12.33.3
- (no CPE)range: < 3.2.3~dev7-4.23.1
- (no CPE)range: < 7.0.5~dev4-11.32.3
- (no CPE)range: < 7.0.5~dev4-11.32.3
- (no CPE)range: < 9.0.2~dev15-3.23.1
- (no CPE)range: < 2.15.2_2.15.2_2.15.2~dev32-11.23.3
- (no CPE)range: < 2.15.2_2.15.2_2.15.2~dev32-11.23.3
- (no CPE)range: < 2.19.2~dev48-2.18.1
- (no CPE)range: < 8.0.2~dev2-11.32.3
- (no CPE)range: < 8.0.2~dev2-11.32.3
Patches
304ac1624bdc2[2.2.x] Fixed CVE-2021-31542 -- Tightened path & file name sanitation in file uploads.
12 files changed · +162 −13
django/core/files/storage.py+7 −0 modified@@ -1,11 +1,13 @@ import os +import pathlib from datetime import datetime from urllib.parse import urljoin from django.conf import settings from django.core.exceptions import SuspiciousFileOperation from django.core.files import File, locks from django.core.files.move import file_move_safe +from django.core.files.utils import validate_file_name from django.core.signals import setting_changed from django.utils import timezone from django.utils._os import safe_join @@ -66,6 +68,9 @@ def get_available_name(self, name, max_length=None): available for new content to be written to. """ dir_name, file_name = os.path.split(name) + if '..' in pathlib.PurePath(dir_name).parts: + raise SuspiciousFileOperation("Detected path traversal attempt in '%s'" % dir_name) + validate_file_name(file_name) file_root, file_ext = os.path.splitext(file_name) # If the filename already exists, add an underscore and a random 7 # character alphanumeric string (before the file extension, if one @@ -98,6 +103,8 @@ def generate_filename(self, filename): """ # `filename` may include a path as returned by FileField.upload_to. dirname, filename = os.path.split(filename) + if '..' in pathlib.PurePath(dirname).parts: + raise SuspiciousFileOperation("Detected path traversal attempt in '%s'" % dirname) return os.path.normpath(os.path.join(dirname, self.get_valid_name(filename))) def path(self, name):
django/core/files/uploadedfile.py+3 −0 modified@@ -8,6 +8,7 @@ from django.conf import settings from django.core.files import temp as tempfile from django.core.files.base import File +from django.core.files.utils import validate_file_name __all__ = ('UploadedFile', 'TemporaryUploadedFile', 'InMemoryUploadedFile', 'SimpleUploadedFile') @@ -47,6 +48,8 @@ def _set_name(self, name): ext = ext[:255] name = name[:255 - len(ext)] + ext + name = validate_file_name(name) + self._name = name name = property(_get_name, _set_name)
django/core/files/utils.py+16 −0 modified@@ -1,3 +1,19 @@ +import os + +from django.core.exceptions import SuspiciousFileOperation + + +def validate_file_name(name): + if name != os.path.basename(name): + raise SuspiciousFileOperation("File name '%s' includes path elements" % name) + + # Remove potentially dangerous names + if name in {'', '.', '..'}: + raise SuspiciousFileOperation("Could not derive file name from '%s'" % name) + + return name + + class FileProxyMixin: """ A mixin class used to forward file methods to an underlaying file
django/db/models/fields/files.py+2 −0 modified@@ -6,6 +6,7 @@ from django.core.files.base import File from django.core.files.images import ImageFile from django.core.files.storage import default_storage +from django.core.files.utils import validate_file_name from django.db.models import signals from django.db.models.fields import Field from django.utils.translation import gettext_lazy as _ @@ -299,6 +300,7 @@ def generate_filename(self, instance, filename): Until the storage layer, all file paths are expected to be Unix style (with forward slashes). """ + filename = validate_file_name(filename) if callable(self.upload_to): filename = self.upload_to(instance, filename) else:
django/http/multipartparser.py+20 −6 modified@@ -7,7 +7,7 @@ import base64 import binascii import cgi -import os +import html from urllib.parse import unquote from django.conf import settings @@ -19,7 +19,6 @@ ) from django.utils.datastructures import MultiValueDict from django.utils.encoding import force_text -from django.utils.text import unescape_entities __all__ = ('MultiPartParser', 'MultiPartParserError', 'InputStreamExhausted') @@ -295,10 +294,25 @@ def handle_file_complete(self, old_field_name, counters): break def sanitize_file_name(self, file_name): - file_name = unescape_entities(file_name) - # Cleanup Windows-style path separators. - file_name = file_name[file_name.rfind('\\') + 1:].strip() - return os.path.basename(file_name) + """ + Sanitize the filename of an upload. + + Remove all possible path separators, even though that might remove more + than actually required by the target system. Filenames that could + potentially cause problems (current/parent dir) are also discarded. + + It should be noted that this function could still return a "filepath" + like "C:some_file.txt" which is handled later on by the storage layer. + So while this function does sanitize filenames to some extent, the + resulting filename should still be considered as untrusted user input. + """ + file_name = html.unescape(file_name) + file_name = file_name.rsplit('/')[-1] + file_name = file_name.rsplit('\\')[-1] + + if file_name in {'', '.', '..'}: + return None + return file_name IE_sanitize = sanitize_file_name
django/utils/text.py+7 −3 modified@@ -4,6 +4,7 @@ from gzip import GzipFile from io import BytesIO +from django.core.exceptions import SuspiciousFileOperation from django.utils.functional import SimpleLazyObject, keep_lazy_text, lazy from django.utils.translation import gettext as _, gettext_lazy, pgettext @@ -216,7 +217,7 @@ def _truncate_html(self, length, truncate, text, truncate_len, words): @keep_lazy_text -def get_valid_filename(s): +def get_valid_filename(name): """ Return the given string converted to a string that can be used for a clean filename. Remove leading and trailing spaces; convert other spaces to @@ -225,8 +226,11 @@ def get_valid_filename(s): >>> get_valid_filename("john's portrait in 2004.jpg") 'johns_portrait_in_2004.jpg' """ - s = str(s).strip().replace(' ', '_') - return re.sub(r'(?u)[^-\w.]', '', s) + s = str(name).strip().replace(' ', '_') + s = re.sub(r'(?u)[^-\w.]', '', s) + if s in {'', '.', '..'}: + raise SuspiciousFileOperation("Could not derive file name from '%s'" % name) + return s @keep_lazy_text
docs/releases/2.2.21.txt+17 −0 added@@ -0,0 +1,17 @@ +=========================== +Django 2.2.21 release notes +=========================== + +*May 4, 2021* + +Django 2.2.21 fixes a security issue in 2.2.20. + +CVE-2021-31542: Potential directory-traversal via uploaded files +================================================================ + +``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed +directory-traversal via uploaded files with suitably crafted file names. + +In order to mitigate this risk, stricter basename and path sanitation is now +applied. Specifically, empty file names and paths with dot segments will be +rejected.
docs/releases/index.txt+1 −0 modified@@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 2.2.21 2.2.20 2.2.19 2.2.18
tests/file_storage/test_generate_filename.py+40 −1 modified@@ -1,7 +1,8 @@ import os +from django.core.exceptions import SuspiciousFileOperation from django.core.files.base import ContentFile -from django.core.files.storage import Storage +from django.core.files.storage import FileSystemStorage, Storage from django.db.models import FileField from django.test import SimpleTestCase @@ -36,6 +37,44 @@ def generate_filename(self, filename): class GenerateFilenameStorageTests(SimpleTestCase): + def test_storage_dangerous_paths(self): + candidates = [ + ('/tmp/..', '..'), + ('/tmp/.', '.'), + ('', ''), + ] + s = FileSystemStorage() + msg = "Could not derive file name from '%s'" + for file_name, base_name in candidates: + with self.subTest(file_name=file_name): + with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name): + s.get_available_name(file_name) + with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name): + s.generate_filename(file_name) + + def test_storage_dangerous_paths_dir_name(self): + file_name = '/tmp/../path' + s = FileSystemStorage() + msg = "Detected path traversal attempt in '/tmp/..'" + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + s.get_available_name(file_name) + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + s.generate_filename(file_name) + + def test_filefield_dangerous_filename(self): + candidates = ['..', '.', '', '???', '$.$.$'] + f = FileField(upload_to='some/folder/') + msg = "Could not derive file name from '%s'" + for file_name in candidates: + with self.subTest(file_name=file_name): + with self.assertRaisesMessage(SuspiciousFileOperation, msg % file_name): + f.generate_filename(None, file_name) + + def test_filefield_dangerous_filename_dir(self): + f = FileField(upload_to='some/folder/') + msg = "File name '/tmp/path' includes path elements" + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + f.generate_filename(None, '/tmp/path') def test_filefield_generate_filename(self): f = FileField(upload_to='some/folder/')
tests/file_uploads/tests.py+37 −1 modified@@ -8,8 +8,9 @@ from io import BytesIO, StringIO from urllib.parse import quote +from django.core.exceptions import SuspiciousFileOperation from django.core.files import temp as tempfile -from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile from django.http.multipartparser import ( MultiPartParser, MultiPartParserError, parse_header, ) @@ -37,6 +38,16 @@ '../hax0rd.txt', # HTML entities. ] +CANDIDATE_INVALID_FILE_NAMES = [ + '/tmp/', # Directory, *nix-style. + 'c:\\tmp\\', # Directory, win-style. + '/tmp/.', # Directory dot, *nix-style. + 'c:\\tmp\\.', # Directory dot, *nix-style. + '/tmp/..', # Parent directory, *nix-style. + 'c:\\tmp\\..', # Parent directory, win-style. + '', # Empty filename. +] + @override_settings(MEDIA_ROOT=MEDIA_ROOT, ROOT_URLCONF='file_uploads.urls', MIDDLEWARE=[]) class FileUploadTests(TestCase): @@ -52,6 +63,22 @@ def tearDownClass(cls): shutil.rmtree(MEDIA_ROOT) super().tearDownClass() + def test_upload_name_is_validated(self): + candidates = [ + '/tmp/', + '/tmp/..', + '/tmp/.', + ] + if sys.platform == 'win32': + candidates.extend([ + 'c:\\tmp\\', + 'c:\\tmp\\..', + 'c:\\tmp\\.', + ]) + for file_name in candidates: + with self.subTest(file_name=file_name): + self.assertRaises(SuspiciousFileOperation, UploadedFile, name=file_name) + def test_simple_upload(self): with open(__file__, 'rb') as fp: post_data = { @@ -631,6 +658,15 @@ def test_sanitize_file_name(self): with self.subTest(file_name=file_name): self.assertEqual(parser.sanitize_file_name(file_name), 'hax0rd.txt') + def test_sanitize_invalid_file_name(self): + parser = MultiPartParser({ + 'CONTENT_TYPE': 'multipart/form-data; boundary=_foo', + 'CONTENT_LENGTH': '1', + }, StringIO('x'), [], 'utf-8') + for file_name in CANDIDATE_INVALID_FILE_NAMES: + with self.subTest(file_name=file_name): + self.assertIsNone(parser.sanitize_file_name(file_name)) + def test_rfc2231_parsing(self): test_data = ( (b"Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A",
tests/forms_tests/field_tests/test_filefield.py+4 −2 modified@@ -20,10 +20,12 @@ def test_filefield_1(self): f.clean(None, '') self.assertEqual('files/test2.pdf', f.clean(None, 'files/test2.pdf')) no_file_msg = "'No file was submitted. Check the encoding type on the form.'" + file = SimpleUploadedFile(None, b'') + file._name = '' with self.assertRaisesMessage(ValidationError, no_file_msg): - f.clean(SimpleUploadedFile('', b'')) + f.clean(file) with self.assertRaisesMessage(ValidationError, no_file_msg): - f.clean(SimpleUploadedFile('', b''), '') + f.clean(file, '') self.assertEqual('files/test3.pdf', f.clean(None, 'files/test3.pdf')) with self.assertRaisesMessage(ValidationError, no_file_msg): f.clean('some content that is not a file')
tests/utils_tests/test_text.py+8 −0 modified@@ -1,6 +1,7 @@ import json import sys +from django.core.exceptions import SuspiciousFileOperation from django.test import SimpleTestCase from django.utils import text from django.utils.functional import lazystr @@ -229,6 +230,13 @@ def test_get_valid_filename(self): filename = "^&'@{}[],$=!-#()%+~_123.txt" self.assertEqual(text.get_valid_filename(filename), "-_123.txt") self.assertEqual(text.get_valid_filename(lazystr(filename)), "-_123.txt") + msg = "Could not derive file name from '???'" + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + text.get_valid_filename('???') + # After sanitizing this would yield '..'. + msg = "Could not derive file name from '$.$.$'" + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + text.get_valid_filename('$.$.$') def test_compress_sequence(self): data = [{'key': i} for i in range(10)]
c98f446c1885[3.2.x] Fixed CVE-2021-31542 -- Tightened path & file name sanitation in file uploads.
14 files changed · +190 −13
django/core/files/storage.py+7 −0 modified@@ -1,11 +1,13 @@ import os +import pathlib from datetime import datetime from urllib.parse import urljoin from django.conf import settings from django.core.exceptions import SuspiciousFileOperation from django.core.files import File, locks from django.core.files.move import file_move_safe +from django.core.files.utils import validate_file_name from django.core.signals import setting_changed from django.utils import timezone from django.utils._os import safe_join @@ -74,6 +76,9 @@ def get_available_name(self, name, max_length=None): available for new content to be written to. """ dir_name, file_name = os.path.split(name) + if '..' in pathlib.PurePath(dir_name).parts: + raise SuspiciousFileOperation("Detected path traversal attempt in '%s'" % dir_name) + validate_file_name(file_name) file_root, file_ext = os.path.splitext(file_name) # If the filename already exists, generate an alternative filename # until it doesn't exist. @@ -105,6 +110,8 @@ def generate_filename(self, filename): """ # `filename` may include a path as returned by FileField.upload_to. dirname, filename = os.path.split(filename) + if '..' in pathlib.PurePath(dirname).parts: + raise SuspiciousFileOperation("Detected path traversal attempt in '%s'" % dirname) return os.path.normpath(os.path.join(dirname, self.get_valid_name(filename))) def path(self, name):
django/core/files/uploadedfile.py+3 −0 modified@@ -8,6 +8,7 @@ from django.conf import settings from django.core.files import temp as tempfile from django.core.files.base import File +from django.core.files.utils import validate_file_name __all__ = ('UploadedFile', 'TemporaryUploadedFile', 'InMemoryUploadedFile', 'SimpleUploadedFile') @@ -47,6 +48,8 @@ def _set_name(self, name): ext = ext[:255] name = name[:255 - len(ext)] + ext + name = validate_file_name(name) + self._name = name name = property(_get_name, _set_name)
django/core/files/utils.py+16 −0 modified@@ -1,3 +1,19 @@ +import os + +from django.core.exceptions import SuspiciousFileOperation + + +def validate_file_name(name): + if name != os.path.basename(name): + raise SuspiciousFileOperation("File name '%s' includes path elements" % name) + + # Remove potentially dangerous names + if name in {'', '.', '..'}: + raise SuspiciousFileOperation("Could not derive file name from '%s'" % name) + + return name + + class FileProxyMixin: """ A mixin class used to forward file methods to an underlaying file
django/db/models/fields/files.py+2 −0 modified@@ -6,6 +6,7 @@ from django.core.files.base import File from django.core.files.images import ImageFile from django.core.files.storage import Storage, default_storage +from django.core.files.utils import validate_file_name from django.db.models import signals from django.db.models.fields import Field from django.db.models.query_utils import DeferredAttribute @@ -312,6 +313,7 @@ def generate_filename(self, instance, filename): Until the storage layer, all file paths are expected to be Unix style (with forward slashes). """ + filename = validate_file_name(filename) if callable(self.upload_to): filename = self.upload_to(instance, filename) else:
django/http/multipartparser.py+18 −4 modified@@ -9,7 +9,6 @@ import cgi import collections import html -import os from urllib.parse import unquote from django.conf import settings @@ -306,10 +305,25 @@ def handle_file_complete(self, old_field_name, counters): break def sanitize_file_name(self, file_name): + """ + Sanitize the filename of an upload. + + Remove all possible path separators, even though that might remove more + than actually required by the target system. Filenames that could + potentially cause problems (current/parent dir) are also discarded. + + It should be noted that this function could still return a "filepath" + like "C:some_file.txt" which is handled later on by the storage layer. + So while this function does sanitize filenames to some extent, the + resulting filename should still be considered as untrusted user input. + """ file_name = html.unescape(file_name) - # Cleanup Windows-style path separators. - file_name = file_name[file_name.rfind('\\') + 1:].strip() - return os.path.basename(file_name) + file_name = file_name.rsplit('/')[-1] + file_name = file_name.rsplit('\\')[-1] + + if file_name in {'', '.', '..'}: + return None + return file_name IE_sanitize = sanitize_file_name
django/utils/text.py+7 −3 modified@@ -5,6 +5,7 @@ from gzip import GzipFile from io import BytesIO +from django.core.exceptions import SuspiciousFileOperation from django.utils.deprecation import RemovedInDjango40Warning from django.utils.functional import SimpleLazyObject, keep_lazy_text, lazy from django.utils.regex_helper import _lazy_re_compile @@ -219,7 +220,7 @@ def _truncate_html(self, length, truncate, text, truncate_len, words): @keep_lazy_text -def get_valid_filename(s): +def get_valid_filename(name): """ Return the given string converted to a string that can be used for a clean filename. Remove leading and trailing spaces; convert other spaces to @@ -228,8 +229,11 @@ def get_valid_filename(s): >>> get_valid_filename("john's portrait in 2004.jpg") 'johns_portrait_in_2004.jpg' """ - s = str(s).strip().replace(' ', '_') - return re.sub(r'(?u)[^-\w.]', '', s) + s = str(name).strip().replace(' ', '_') + s = re.sub(r'(?u)[^-\w.]', '', s) + if s in {'', '.', '..'}: + raise SuspiciousFileOperation("Could not derive file name from '%s'" % name) + return s @keep_lazy_text
docs/releases/2.2.21.txt+17 −0 added@@ -0,0 +1,17 @@ +=========================== +Django 2.2.21 release notes +=========================== + +*May 4, 2021* + +Django 2.2.21 fixes a security issue in 2.2.20. + +CVE-2021-31542: Potential directory-traversal via uploaded files +================================================================ + +``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed +directory-traversal via uploaded files with suitably crafted file names. + +In order to mitigate this risk, stricter basename and path sanitation is now +applied. Specifically, empty file names and paths with dot segments will be +rejected.
docs/releases/3.1.9.txt+17 −0 added@@ -0,0 +1,17 @@ +========================== +Django 3.1.9 release notes +========================== + +*May 4, 2021* + +Django 3.1.9 fixes a security issue in 3.1.8. + +CVE-2021-31542: Potential directory-traversal via uploaded files +================================================================ + +``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed +directory-traversal via uploaded files with suitably crafted file names. + +In order to mitigate this risk, stricter basename and path sanitation is now +applied. Specifically, empty file names and paths with dot segments will be +rejected.
docs/releases/3.2.1.txt+12 −2 modified@@ -2,9 +2,19 @@ Django 3.2.1 release notes ========================== -*Expected May 4, 2021* +*May 4, 2021* -Django 3.2.1 fixes several bugs in 3.2. +Django 3.2.1 fixes a security issue and several bugs in 3.2. + +CVE-2021-31542: Potential directory-traversal via uploaded files +================================================================ + +``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed +directory-traversal via uploaded files with suitably crafted file names. + +In order to mitigate this risk, stricter basename and path sanitation is now +applied. Specifically, empty file names and paths with dot segments will be +rejected. Bugfixes ========
docs/releases/index.txt+2 −0 modified@@ -33,6 +33,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 3.1.9 3.1.8 3.1.7 3.1.6 @@ -69,6 +70,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 2.2.21 2.2.20 2.2.19 2.2.18
tests/file_storage/test_generate_filename.py+40 −1 modified@@ -1,7 +1,8 @@ import os +from django.core.exceptions import SuspiciousFileOperation from django.core.files.base import ContentFile -from django.core.files.storage import Storage +from django.core.files.storage import FileSystemStorage, Storage from django.db.models import FileField from django.test import SimpleTestCase @@ -36,6 +37,44 @@ def generate_filename(self, filename): class GenerateFilenameStorageTests(SimpleTestCase): + def test_storage_dangerous_paths(self): + candidates = [ + ('/tmp/..', '..'), + ('/tmp/.', '.'), + ('', ''), + ] + s = FileSystemStorage() + msg = "Could not derive file name from '%s'" + for file_name, base_name in candidates: + with self.subTest(file_name=file_name): + with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name): + s.get_available_name(file_name) + with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name): + s.generate_filename(file_name) + + def test_storage_dangerous_paths_dir_name(self): + file_name = '/tmp/../path' + s = FileSystemStorage() + msg = "Detected path traversal attempt in '/tmp/..'" + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + s.get_available_name(file_name) + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + s.generate_filename(file_name) + + def test_filefield_dangerous_filename(self): + candidates = ['..', '.', '', '???', '$.$.$'] + f = FileField(upload_to='some/folder/') + msg = "Could not derive file name from '%s'" + for file_name in candidates: + with self.subTest(file_name=file_name): + with self.assertRaisesMessage(SuspiciousFileOperation, msg % file_name): + f.generate_filename(None, file_name) + + def test_filefield_dangerous_filename_dir(self): + f = FileField(upload_to='some/folder/') + msg = "File name '/tmp/path' includes path elements" + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + f.generate_filename(None, '/tmp/path') def test_filefield_generate_filename(self): f = FileField(upload_to='some/folder/')
tests/file_uploads/tests.py+37 −1 modified@@ -9,8 +9,9 @@ from unittest import mock from urllib.parse import quote +from django.core.exceptions import SuspiciousFileOperation from django.core.files import temp as tempfile -from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile from django.http.multipartparser import ( FILE, MultiPartParser, MultiPartParserError, Parser, parse_header, ) @@ -39,6 +40,16 @@ '../hax0rd.txt', # HTML entities. ] +CANDIDATE_INVALID_FILE_NAMES = [ + '/tmp/', # Directory, *nix-style. + 'c:\\tmp\\', # Directory, win-style. + '/tmp/.', # Directory dot, *nix-style. + 'c:\\tmp\\.', # Directory dot, *nix-style. + '/tmp/..', # Parent directory, *nix-style. + 'c:\\tmp\\..', # Parent directory, win-style. + '', # Empty filename. +] + @override_settings(MEDIA_ROOT=MEDIA_ROOT, ROOT_URLCONF='file_uploads.urls', MIDDLEWARE=[]) class FileUploadTests(TestCase): @@ -53,6 +64,22 @@ def tearDownClass(cls): shutil.rmtree(MEDIA_ROOT) super().tearDownClass() + def test_upload_name_is_validated(self): + candidates = [ + '/tmp/', + '/tmp/..', + '/tmp/.', + ] + if sys.platform == 'win32': + candidates.extend([ + 'c:\\tmp\\', + 'c:\\tmp\\..', + 'c:\\tmp\\.', + ]) + for file_name in candidates: + with self.subTest(file_name=file_name): + self.assertRaises(SuspiciousFileOperation, UploadedFile, name=file_name) + def test_simple_upload(self): with open(__file__, 'rb') as fp: post_data = { @@ -718,6 +745,15 @@ def test_sanitize_file_name(self): with self.subTest(file_name=file_name): self.assertEqual(parser.sanitize_file_name(file_name), 'hax0rd.txt') + def test_sanitize_invalid_file_name(self): + parser = MultiPartParser({ + 'CONTENT_TYPE': 'multipart/form-data; boundary=_foo', + 'CONTENT_LENGTH': '1', + }, StringIO('x'), [], 'utf-8') + for file_name in CANDIDATE_INVALID_FILE_NAMES: + with self.subTest(file_name=file_name): + self.assertIsNone(parser.sanitize_file_name(file_name)) + def test_rfc2231_parsing(self): test_data = ( (b"Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A",
tests/forms_tests/field_tests/test_filefield.py+4 −2 modified@@ -21,10 +21,12 @@ def test_filefield_1(self): f.clean(None, '') self.assertEqual('files/test2.pdf', f.clean(None, 'files/test2.pdf')) no_file_msg = "'No file was submitted. Check the encoding type on the form.'" + file = SimpleUploadedFile(None, b'') + file._name = '' with self.assertRaisesMessage(ValidationError, no_file_msg): - f.clean(SimpleUploadedFile('', b'')) + f.clean(file) with self.assertRaisesMessage(ValidationError, no_file_msg): - f.clean(SimpleUploadedFile('', b''), '') + f.clean(file, '') self.assertEqual('files/test3.pdf', f.clean(None, 'files/test3.pdf')) with self.assertRaisesMessage(ValidationError, no_file_msg): f.clean('some content that is not a file')
tests/utils_tests/test_text.py+8 −0 modified@@ -1,6 +1,7 @@ import json import sys +from django.core.exceptions import SuspiciousFileOperation from django.test import SimpleTestCase, ignore_warnings from django.utils import text from django.utils.deprecation import RemovedInDjango40Warning @@ -255,6 +256,13 @@ def test_get_valid_filename(self): filename = "^&'@{}[],$=!-#()%+~_123.txt" self.assertEqual(text.get_valid_filename(filename), "-_123.txt") self.assertEqual(text.get_valid_filename(lazystr(filename)), "-_123.txt") + msg = "Could not derive file name from '???'" + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + text.get_valid_filename('???') + # After sanitizing this would yield '..'. + msg = "Could not derive file name from '$.$.$'" + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + text.get_valid_filename('$.$.$') def test_compress_sequence(self): data = [{'key': i} for i in range(10)]
25d84d64122c[3.1.x] Fixed CVE-2021-31542 -- Tightened path & file name sanitation in file uploads.
13 files changed · +178 −11
django/core/files/storage.py+7 −0 modified@@ -1,11 +1,13 @@ import os +import pathlib from datetime import datetime from urllib.parse import urljoin from django.conf import settings from django.core.exceptions import SuspiciousFileOperation from django.core.files import File, locks from django.core.files.move import file_move_safe +from django.core.files.utils import validate_file_name from django.core.signals import setting_changed from django.utils import timezone from django.utils._os import safe_join @@ -74,6 +76,9 @@ def get_available_name(self, name, max_length=None): available for new content to be written to. """ dir_name, file_name = os.path.split(name) + if '..' in pathlib.PurePath(dir_name).parts: + raise SuspiciousFileOperation("Detected path traversal attempt in '%s'" % dir_name) + validate_file_name(file_name) file_root, file_ext = os.path.splitext(file_name) # If the filename already exists, generate an alternative filename # until it doesn't exist. @@ -105,6 +110,8 @@ def generate_filename(self, filename): """ # `filename` may include a path as returned by FileField.upload_to. dirname, filename = os.path.split(filename) + if '..' in pathlib.PurePath(dirname).parts: + raise SuspiciousFileOperation("Detected path traversal attempt in '%s'" % dirname) return os.path.normpath(os.path.join(dirname, self.get_valid_name(filename))) def path(self, name):
django/core/files/uploadedfile.py+3 −0 modified@@ -8,6 +8,7 @@ from django.conf import settings from django.core.files import temp as tempfile from django.core.files.base import File +from django.core.files.utils import validate_file_name __all__ = ('UploadedFile', 'TemporaryUploadedFile', 'InMemoryUploadedFile', 'SimpleUploadedFile') @@ -47,6 +48,8 @@ def _set_name(self, name): ext = ext[:255] name = name[:255 - len(ext)] + ext + name = validate_file_name(name) + self._name = name name = property(_get_name, _set_name)
django/core/files/utils.py+16 −0 modified@@ -1,3 +1,19 @@ +import os + +from django.core.exceptions import SuspiciousFileOperation + + +def validate_file_name(name): + if name != os.path.basename(name): + raise SuspiciousFileOperation("File name '%s' includes path elements" % name) + + # Remove potentially dangerous names + if name in {'', '.', '..'}: + raise SuspiciousFileOperation("Could not derive file name from '%s'" % name) + + return name + + class FileProxyMixin: """ A mixin class used to forward file methods to an underlaying file
django/db/models/fields/files.py+2 −0 modified@@ -6,6 +6,7 @@ from django.core.files.base import File from django.core.files.images import ImageFile from django.core.files.storage import Storage, default_storage +from django.core.files.utils import validate_file_name from django.db.models import signals from django.db.models.fields import Field from django.utils.translation import gettext_lazy as _ @@ -318,6 +319,7 @@ def generate_filename(self, instance, filename): Until the storage layer, all file paths are expected to be Unix style (with forward slashes). """ + filename = validate_file_name(filename) if callable(self.upload_to): filename = self.upload_to(instance, filename) else:
django/http/multipartparser.py+18 −4 modified@@ -9,7 +9,6 @@ import cgi import collections import html -import os from urllib.parse import unquote from django.conf import settings @@ -299,10 +298,25 @@ def handle_file_complete(self, old_field_name, counters): break def sanitize_file_name(self, file_name): + """ + Sanitize the filename of an upload. + + Remove all possible path separators, even though that might remove more + than actually required by the target system. Filenames that could + potentially cause problems (current/parent dir) are also discarded. + + It should be noted that this function could still return a "filepath" + like "C:some_file.txt" which is handled later on by the storage layer. + So while this function does sanitize filenames to some extent, the + resulting filename should still be considered as untrusted user input. + """ file_name = html.unescape(file_name) - # Cleanup Windows-style path separators. - file_name = file_name[file_name.rfind('\\') + 1:].strip() - return os.path.basename(file_name) + file_name = file_name.rsplit('/')[-1] + file_name = file_name.rsplit('\\')[-1] + + if file_name in {'', '.', '..'}: + return None + return file_name IE_sanitize = sanitize_file_name
django/utils/text.py+7 −3 modified@@ -5,6 +5,7 @@ from gzip import GzipFile from io import BytesIO +from django.core.exceptions import SuspiciousFileOperation from django.utils.deprecation import RemovedInDjango40Warning from django.utils.functional import SimpleLazyObject, keep_lazy_text, lazy from django.utils.regex_helper import _lazy_re_compile @@ -219,7 +220,7 @@ def _truncate_html(self, length, truncate, text, truncate_len, words): @keep_lazy_text -def get_valid_filename(s): +def get_valid_filename(name): """ Return the given string converted to a string that can be used for a clean filename. Remove leading and trailing spaces; convert other spaces to @@ -228,8 +229,11 @@ def get_valid_filename(s): >>> get_valid_filename("john's portrait in 2004.jpg") 'johns_portrait_in_2004.jpg' """ - s = str(s).strip().replace(' ', '_') - return re.sub(r'(?u)[^-\w.]', '', s) + s = str(name).strip().replace(' ', '_') + s = re.sub(r'(?u)[^-\w.]', '', s) + if s in {'', '.', '..'}: + raise SuspiciousFileOperation("Could not derive file name from '%s'" % name) + return s @keep_lazy_text
docs/releases/2.2.21.txt+17 −0 added@@ -0,0 +1,17 @@ +=========================== +Django 2.2.21 release notes +=========================== + +*May 4, 2021* + +Django 2.2.21 fixes a security issue in 2.2.20. + +CVE-2021-31542: Potential directory-traversal via uploaded files +================================================================ + +``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed +directory-traversal via uploaded files with suitably crafted file names. + +In order to mitigate this risk, stricter basename and path sanitation is now +applied. Specifically, empty file names and paths with dot segments will be +rejected.
docs/releases/3.1.9.txt+17 −0 added@@ -0,0 +1,17 @@ +========================== +Django 3.1.9 release notes +========================== + +*May 4, 2021* + +Django 3.1.9 fixes a security issue in 3.1.8. + +CVE-2021-31542: Potential directory-traversal via uploaded files +================================================================ + +``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed +directory-traversal via uploaded files with suitably crafted file names. + +In order to mitigate this risk, stricter basename and path sanitation is now +applied. Specifically, empty file names and paths with dot segments will be +rejected.
docs/releases/index.txt+2 −0 modified@@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 3.1.9 3.1.8 3.1.7 3.1.6 @@ -61,6 +62,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 2.2.21 2.2.20 2.2.19 2.2.18
tests/file_storage/test_generate_filename.py+40 −1 modified@@ -1,7 +1,8 @@ import os +from django.core.exceptions import SuspiciousFileOperation from django.core.files.base import ContentFile -from django.core.files.storage import Storage +from django.core.files.storage import FileSystemStorage, Storage from django.db.models import FileField from django.test import SimpleTestCase @@ -36,6 +37,44 @@ def generate_filename(self, filename): class GenerateFilenameStorageTests(SimpleTestCase): + def test_storage_dangerous_paths(self): + candidates = [ + ('/tmp/..', '..'), + ('/tmp/.', '.'), + ('', ''), + ] + s = FileSystemStorage() + msg = "Could not derive file name from '%s'" + for file_name, base_name in candidates: + with self.subTest(file_name=file_name): + with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name): + s.get_available_name(file_name) + with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name): + s.generate_filename(file_name) + + def test_storage_dangerous_paths_dir_name(self): + file_name = '/tmp/../path' + s = FileSystemStorage() + msg = "Detected path traversal attempt in '/tmp/..'" + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + s.get_available_name(file_name) + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + s.generate_filename(file_name) + + def test_filefield_dangerous_filename(self): + candidates = ['..', '.', '', '???', '$.$.$'] + f = FileField(upload_to='some/folder/') + msg = "Could not derive file name from '%s'" + for file_name in candidates: + with self.subTest(file_name=file_name): + with self.assertRaisesMessage(SuspiciousFileOperation, msg % file_name): + f.generate_filename(None, file_name) + + def test_filefield_dangerous_filename_dir(self): + f = FileField(upload_to='some/folder/') + msg = "File name '/tmp/path' includes path elements" + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + f.generate_filename(None, '/tmp/path') def test_filefield_generate_filename(self): f = FileField(upload_to='some/folder/')
tests/file_uploads/tests.py+37 −1 modified@@ -8,8 +8,9 @@ from io import BytesIO, StringIO from urllib.parse import quote +from django.core.exceptions import SuspiciousFileOperation from django.core.files import temp as tempfile -from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile from django.http.multipartparser import ( MultiPartParser, MultiPartParserError, parse_header, ) @@ -38,6 +39,16 @@ '../hax0rd.txt', # HTML entities. ] +CANDIDATE_INVALID_FILE_NAMES = [ + '/tmp/', # Directory, *nix-style. + 'c:\\tmp\\', # Directory, win-style. + '/tmp/.', # Directory dot, *nix-style. + 'c:\\tmp\\.', # Directory dot, *nix-style. + '/tmp/..', # Parent directory, *nix-style. + 'c:\\tmp\\..', # Parent directory, win-style. + '', # Empty filename. +] + @override_settings(MEDIA_ROOT=MEDIA_ROOT, ROOT_URLCONF='file_uploads.urls', MIDDLEWARE=[]) class FileUploadTests(TestCase): @@ -52,6 +63,22 @@ def tearDownClass(cls): shutil.rmtree(MEDIA_ROOT) super().tearDownClass() + def test_upload_name_is_validated(self): + candidates = [ + '/tmp/', + '/tmp/..', + '/tmp/.', + ] + if sys.platform == 'win32': + candidates.extend([ + 'c:\\tmp\\', + 'c:\\tmp\\..', + 'c:\\tmp\\.', + ]) + for file_name in candidates: + with self.subTest(file_name=file_name): + self.assertRaises(SuspiciousFileOperation, UploadedFile, name=file_name) + def test_simple_upload(self): with open(__file__, 'rb') as fp: post_data = { @@ -685,6 +712,15 @@ def test_sanitize_file_name(self): with self.subTest(file_name=file_name): self.assertEqual(parser.sanitize_file_name(file_name), 'hax0rd.txt') + def test_sanitize_invalid_file_name(self): + parser = MultiPartParser({ + 'CONTENT_TYPE': 'multipart/form-data; boundary=_foo', + 'CONTENT_LENGTH': '1', + }, StringIO('x'), [], 'utf-8') + for file_name in CANDIDATE_INVALID_FILE_NAMES: + with self.subTest(file_name=file_name): + self.assertIsNone(parser.sanitize_file_name(file_name)) + def test_rfc2231_parsing(self): test_data = ( (b"Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A",
tests/forms_tests/field_tests/test_filefield.py+4 −2 modified@@ -21,10 +21,12 @@ def test_filefield_1(self): f.clean(None, '') self.assertEqual('files/test2.pdf', f.clean(None, 'files/test2.pdf')) no_file_msg = "'No file was submitted. Check the encoding type on the form.'" + file = SimpleUploadedFile(None, b'') + file._name = '' with self.assertRaisesMessage(ValidationError, no_file_msg): - f.clean(SimpleUploadedFile('', b'')) + f.clean(file) with self.assertRaisesMessage(ValidationError, no_file_msg): - f.clean(SimpleUploadedFile('', b''), '') + f.clean(file, '') self.assertEqual('files/test3.pdf', f.clean(None, 'files/test3.pdf')) with self.assertRaisesMessage(ValidationError, no_file_msg): f.clean('some content that is not a file')
tests/utils_tests/test_text.py+8 −0 modified@@ -1,6 +1,7 @@ import json import sys +from django.core.exceptions import SuspiciousFileOperation from django.test import SimpleTestCase, ignore_warnings from django.utils import text from django.utils.deprecation import RemovedInDjango40Warning @@ -243,6 +244,13 @@ def test_get_valid_filename(self): filename = "^&'@{}[],$=!-#()%+~_123.txt" self.assertEqual(text.get_valid_filename(filename), "-_123.txt") self.assertEqual(text.get_valid_filename(lazystr(filename)), "-_123.txt") + msg = "Could not derive file name from '???'" + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + text.get_valid_filename('???') + # After sanitizing this would yield '..'. + msg = "Could not derive file name from '$.$.$'" + with self.assertRaisesMessage(SuspiciousFileOperation, msg): + text.get_valid_filename('$.$.$') def test_compress_sequence(self): data = [{'key': i} for i in range(10)]
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
22- github.com/advisories/GHSA-rxjp-mfm9-w4wrghsaADVISORY
- lists.fedoraproject.org/archives/list/package-announce%40lists.fedoraproject.org/message/B4SQG2EAF4WCI2SLRL6XRDJ3RPK3ZRDV/mitrevendor-advisory
- lists.fedoraproject.org/archives/list/package-announce%40lists.fedoraproject.org/message/ZVKYPHR3TKR2ESWXBPOJEKRO2OSJRZUE/mitrevendor-advisory
- nvd.nist.gov/vuln/detail/CVE-2021-31542ghsaADVISORY
- www.openwall.com/lists/oss-security/2021/05/04/3ghsaWEB
- docs.djangoproject.com/en/3.2/releases/securityghsaWEB
- github.com/django/django/commit/04ac1624bdc2fa737188401757cf95ced122d26dghsaWEB
- github.com/django/django/commit/25d84d64122c15050a0ee739e859f22ddab5ac48ghsaWEB
- github.com/django/django/commit/c98f446c188596d4ba6de71d1b77b4a6c5c2a007ghsaWEB
- github.com/pypa/advisory-database/tree/main/vulns/django/PYSEC-2021-7.yamlghsaWEB
- groups.google.com/forum/ghsaWEB
- groups.google.com/forum/ghsaWEB
- lists.debian.org/debian-lts-announce/2021/05/msg00005.htmlghsamailing-listWEB
- lists.fedoraproject.org/archives/list/package-announce%40lists.fedoraproject.org/message/B4SQG2EAF4WCI2SLRL6XRDJ3RPK3ZRDVghsaWEB
- lists.fedoraproject.org/archives/list/package-announce%40lists.fedoraproject.org/message/ZVKYPHR3TKR2ESWXBPOJEKRO2OSJRZUEghsaWEB
- lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/B4SQG2EAF4WCI2SLRL6XRDJ3RPK3ZRDVghsaWEB
- lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/ZVKYPHR3TKR2ESWXBPOJEKRO2OSJRZUEghsaWEB
- security.netapp.com/advisory/ntap-20210618-0001ghsaWEB
- www.djangoproject.com/weblog/2021/may/04/security-releasesghsaWEB
- docs.djangoproject.com/en/3.2/releases/security/mitre
- security.netapp.com/advisory/ntap-20210618-0001/mitre
- www.djangoproject.com/weblog/2021/may/04/security-releases/mitre
News mentions
0No linked articles in our index yet.