summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--django/core/handlers/asgi.py3
-rw-r--r--django/test/client.py5
-rw-r--r--docs/releases/4.2.30.txt20
-rw-r--r--docs/releases/5.2.13.txt20
-rw-r--r--docs/releases/6.0.4.txt20
-rw-r--r--tests/asgi/tests.py11
6 files changed, 78 insertions, 1 deletions
diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py
index 1ca9130a78..4ce7a31716 100644
--- a/django/core/handlers/asgi.py
+++ b/django/core/handlers/asgi.py
@@ -87,6 +87,9 @@ class ASGIRequest(HttpRequest):
_headers = defaultdict(list)
for name, value in self.scope.get("headers", []):
name = name.decode("latin1")
+ # Prevent spoofing via ambiguity between underscores and hyphens.
+ if "_" in name:
+ continue
if name == "content-length":
corrected_name = "CONTENT_LENGTH"
elif name == "content-type":
diff --git a/django/test/client.py b/django/test/client.py
index 301399c9ce..6fa7b564f1 100644
--- a/django/test/client.py
+++ b/django/test/client.py
@@ -773,7 +773,10 @@ class AsyncRequestFactory(RequestFactory):
if headers:
extra.update(HttpHeaders.to_asgi_names(headers))
s["headers"] += [
- (key.lower().encode("ascii"), value.encode("latin1"))
+ # Avoid breaking test clients that just want to supply normalized
+ # ASGI names, regardless of the fact that ASGIRequest drops headers
+ # with underscores (CVE-2026-3902).
+ (key.lower().replace("_", "-").encode("ascii"), value.encode("latin1"))
for key, value in extra.items()
]
return self.request(**s)
diff --git a/docs/releases/4.2.30.txt b/docs/releases/4.2.30.txt
index a2679c7736..30ffd4eb9d 100644
--- a/docs/releases/4.2.30.txt
+++ b/docs/releases/4.2.30.txt
@@ -6,3 +6,23 @@ Django 4.2.30 release notes
Django 4.2.30 fixes one security issue with severity "moderate" and four
security issues with severity "low" in 4.2.29.
+
+CVE-2026-3902: ASGI header spoofing via underscore/hyphen conflation
+====================================================================
+
+``ASGIRequest`` normalizes header names following WSGI conventions, mapping
+hyphens to underscores. As a result, even in configurations where reverse
+proxies carefully strip security-sensitive headers named with hyphens, such a
+header could be spoofed by supplying a header named with underscores.
+
+Under WSGI, it is the responsibility of the server or proxy to avoid ambiguous
+mappings. (Django's :djadmin:`runserver` was patched in :cve:`2015-0219`.) But
+under ASGI, there is not the same uniform expectation, even if many proxies
+protect against this under default configuration (including ``nginx`` via
+``underscores_in_headers off;``).
+
+Headers containing underscores are now ignored by ``ASGIRequest``, matching the
+behavior of :pypi:`Daphne <daphne>`, the reference server for ASGI.
+
+This issue has severity "low" according to the :ref:`Django security policy
+<security-disclosure>`.
diff --git a/docs/releases/5.2.13.txt b/docs/releases/5.2.13.txt
index ff391eff0f..94d63dafdb 100644
--- a/docs/releases/5.2.13.txt
+++ b/docs/releases/5.2.13.txt
@@ -6,3 +6,23 @@ Django 5.2.13 release notes
Django 5.2.13 fixes one security issue with severity "moderate" and four
security issues with severity "low" in 5.2.12.
+
+CVE-2026-3902: ASGI header spoofing via underscore/hyphen conflation
+====================================================================
+
+``ASGIRequest`` normalizes header names following WSGI conventions, mapping
+hyphens to underscores. As a result, even in configurations where reverse
+proxies carefully strip security-sensitive headers named with hyphens, such a
+header could be spoofed by supplying a header named with underscores.
+
+Under WSGI, it is the responsibility of the server or proxy to avoid ambiguous
+mappings. (Django's :djadmin:`runserver` was patched in :cve:`2015-0219`.) But
+under ASGI, there is not the same uniform expectation, even if many proxies
+protect against this under default configuration (including ``nginx`` via
+``underscores_in_headers off;``).
+
+Headers containing underscores are now ignored by ``ASGIRequest``, matching the
+behavior of :pypi:`Daphne <daphne>`, the reference server for ASGI.
+
+This issue has severity "low" according to the :ref:`Django security policy
+<security-disclosure>`.
diff --git a/docs/releases/6.0.4.txt b/docs/releases/6.0.4.txt
index de75dc7d13..0ee6b82988 100644
--- a/docs/releases/6.0.4.txt
+++ b/docs/releases/6.0.4.txt
@@ -7,6 +7,26 @@ Django 6.0.4 release notes
Django 6.0.4 fixes one security issue with severity "moderate", four security
issues with severity "low", and several bugs in 6.0.3.
+CVE-2026-3902: ASGI header spoofing via underscore/hyphen conflation
+====================================================================
+
+``ASGIRequest`` normalizes header names following WSGI conventions, mapping
+hyphens to underscores. As a result, even in configurations where reverse
+proxies carefully strip security-sensitive headers named with hyphens, such a
+header could be spoofed by supplying a header named with underscores.
+
+Under WSGI, it is the responsibility of the server or proxy to avoid ambiguous
+mappings. (Django's :djadmin:`runserver` was patched in :cve:`2015-0219`.) But
+under ASGI, there is not the same uniform expectation, even if many proxies
+protect against this under default configuration (including ``nginx`` via
+``underscores_in_headers off;``).
+
+Headers containing underscores are now ignored by ``ASGIRequest``, matching the
+behavior of :pypi:`Daphne <daphne>`, the reference server for ASGI.
+
+This issue has severity "low" according to the :ref:`Django security policy
+<security-disclosure>`.
+
Bugfixes
========
diff --git a/tests/asgi/tests.py b/tests/asgi/tests.py
index 6d74903e41..58429477e2 100644
--- a/tests/asgi/tests.py
+++ b/tests/asgi/tests.py
@@ -280,6 +280,17 @@ class ASGITest(SimpleTestCase):
self.assertEqual(len(request.headers["foo"].split(",")), 200_000)
self.assertLessEqual(setitem_count, 100)
+ async def test_underscores_in_headers_ignored(self):
+ scope = self.async_request_factory._base_scope(path="/", http_version="2.0")
+ scope["headers"] = [(b"some_header", b"1")]
+ request = ASGIRequest(scope, None)
+ # No form of the header exists anywhere.
+ self.assertNotIn("Some_Header", request.headers)
+ self.assertNotIn("Some-Header", request.headers)
+ self.assertNotIn("SOME_HEADER", request.META)
+ self.assertNotIn("SOME-HEADER", request.META)
+ self.assertNotIn("HTTP_SOME_HEADER", request.META)
+
async def test_cancel_post_request_with_sync_processing(self):
"""
The request.body object should be available and readable in view