diff options
| -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-- | docs/releases/5.2.13.txt | 20 | ||||
| -rw-r--r-- | docs/releases/6.0.4.txt | 20 | ||||
| -rw-r--r-- | tests/asgi/tests.py | 11 |
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 |
