diff options
| author | Jacob Walls <jacobtylerwalls@gmail.com> | 2026-01-22 17:01:46 -0500 |
|---|---|---|
| committer | Jacob Walls <jacobtylerwalls@gmail.com> | 2026-04-07 07:41:16 -0400 |
| commit | 4412731aa64d62a6dd7edae79e0c15b72666d7ca (patch) | |
| tree | a751a5cf7e1603380684462b51bea5db09a2c9f5 | |
| parent | 8d2a05c35dafc71d21fc68a6eb81aa6cdd190270 (diff) | |
[4.2.x] Fixed CVE-2026-3902 -- Ignored headers with underscores in ASGIRequest.
Thanks Tarek Nakkouch for the report and Jake Howard and Natalia Bidart
for reviews.
Backport of caf90a971f09323775ed0cacf94eadaf39d040e0 from main.
| -rw-r--r-- | django/core/handlers/asgi.py | 3 | ||||
| -rw-r--r-- | django/test/client.py | 5 | ||||
| -rw-r--r-- | docs/releases/4.2.30.txt | 20 | ||||
| -rw-r--r-- | tests/asgi/tests.py | 11 |
4 files changed, 38 insertions, 1 deletions
diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py index d951823118..3b10a59ec7 100644 --- a/django/core/handlers/asgi.py +++ b/django/core/handlers/asgi.py @@ -85,6 +85,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 cf63265faa..a465cc98f5 100644 --- a/django/test/client.py +++ b/django/test/client.py @@ -705,7 +705,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() ] # If QUERY_STRING is absent or empty, we want to extract it from the 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/tests/asgi/tests.py b/tests/asgi/tests.py index 9395c8626c..bf623c2083 100644 --- a/tests/asgi/tests.py +++ b/tests/asgi/tests.py @@ -220,6 +220,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_untouched_request_body_gets_closed(self): application = get_asgi_application() scope = self.async_request_factory._base_scope(method="POST", path="/post/") |
