Skip to content

Commit a623c39

Browse files
[6.0.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 caf90a9 from main.
1 parent ffc83c5 commit a623c39

6 files changed

Lines changed: 78 additions & 1 deletion

File tree

django/core/handlers/asgi.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ def __init__(self, scope, body_file):
8787
_headers = defaultdict(list)
8888
for name, value in self.scope.get("headers", []):
8989
name = name.decode("latin1")
90+
# Prevent spoofing via ambiguity between underscores and hyphens.
91+
if "_" in name:
92+
continue
9093
if name == "content-length":
9194
corrected_name = "CONTENT_LENGTH"
9295
elif name == "content-type":

django/test/client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -773,7 +773,10 @@ def generic(
773773
if headers:
774774
extra.update(HttpHeaders.to_asgi_names(headers))
775775
s["headers"] += [
776-
(key.lower().encode("ascii"), value.encode("latin1"))
776+
# Avoid breaking test clients that just want to supply normalized
777+
# ASGI names, regardless of the fact that ASGIRequest drops headers
778+
# with underscores (CVE-2026-3902).
779+
(key.lower().replace("_", "-").encode("ascii"), value.encode("latin1"))
777780
for key, value in extra.items()
778781
]
779782
return self.request(**s)

docs/releases/4.2.30.txt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,23 @@ Django 4.2.30 release notes
66

77
Django 4.2.30 fixes one security issue with severity "moderate" and four
88
security issues with severity "low" in 4.2.29.
9+
10+
CVE-2026-3902: ASGI header spoofing via underscore/hyphen conflation
11+
====================================================================
12+
13+
``ASGIRequest`` normalizes header names following WSGI conventions, mapping
14+
hyphens to underscores. As a result, even in configurations where reverse
15+
proxies carefully strip security-sensitive headers named with hyphens, such a
16+
header could be spoofed by supplying a header named with underscores.
17+
18+
Under WSGI, it is the responsibility of the server or proxy to avoid ambiguous
19+
mappings. (Django's :djadmin:`runserver` was patched in :cve:`2015-0219`.) But
20+
under ASGI, there is not the same uniform expectation, even if many proxies
21+
protect against this under default configuration (including ``nginx`` via
22+
``underscores_in_headers off;``).
23+
24+
Headers containing underscores are now ignored by ``ASGIRequest``, matching the
25+
behavior of :pypi:`Daphne <daphne>`, the reference server for ASGI.
26+
27+
This issue has severity "low" according to the :ref:`Django security policy
28+
<security-disclosure>`.

docs/releases/5.2.13.txt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,23 @@ Django 5.2.13 release notes
66

77
Django 5.2.13 fixes one security issue with severity "moderate" and four
88
security issues with severity "low" in 5.2.12.
9+
10+
CVE-2026-3902: ASGI header spoofing via underscore/hyphen conflation
11+
====================================================================
12+
13+
``ASGIRequest`` normalizes header names following WSGI conventions, mapping
14+
hyphens to underscores. As a result, even in configurations where reverse
15+
proxies carefully strip security-sensitive headers named with hyphens, such a
16+
header could be spoofed by supplying a header named with underscores.
17+
18+
Under WSGI, it is the responsibility of the server or proxy to avoid ambiguous
19+
mappings. (Django's :djadmin:`runserver` was patched in :cve:`2015-0219`.) But
20+
under ASGI, there is not the same uniform expectation, even if many proxies
21+
protect against this under default configuration (including ``nginx`` via
22+
``underscores_in_headers off;``).
23+
24+
Headers containing underscores are now ignored by ``ASGIRequest``, matching the
25+
behavior of :pypi:`Daphne <daphne>`, the reference server for ASGI.
26+
27+
This issue has severity "low" according to the :ref:`Django security policy
28+
<security-disclosure>`.

docs/releases/6.0.4.txt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ Django 6.0.4 release notes
77
Django 6.0.4 fixes one security issue with severity "moderate", four security
88
issues with severity "low", and several bugs in 6.0.3.
99

10+
CVE-2026-3902: ASGI header spoofing via underscore/hyphen conflation
11+
====================================================================
12+
13+
``ASGIRequest`` normalizes header names following WSGI conventions, mapping
14+
hyphens to underscores. As a result, even in configurations where reverse
15+
proxies carefully strip security-sensitive headers named with hyphens, such a
16+
header could be spoofed by supplying a header named with underscores.
17+
18+
Under WSGI, it is the responsibility of the server or proxy to avoid ambiguous
19+
mappings. (Django's :djadmin:`runserver` was patched in :cve:`2015-0219`.) But
20+
under ASGI, there is not the same uniform expectation, even if many proxies
21+
protect against this under default configuration (including ``nginx`` via
22+
``underscores_in_headers off;``).
23+
24+
Headers containing underscores are now ignored by ``ASGIRequest``, matching the
25+
behavior of :pypi:`Daphne <daphne>`, the reference server for ASGI.
26+
27+
This issue has severity "low" according to the :ref:`Django security policy
28+
<security-disclosure>`.
29+
1030
Bugfixes
1131
========
1232

tests/asgi/tests.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,17 @@ def META(self, value):
280280
self.assertEqual(len(request.headers["foo"].split(",")), 200_000)
281281
self.assertLessEqual(setitem_count, 100)
282282

283+
async def test_underscores_in_headers_ignored(self):
284+
scope = self.async_request_factory._base_scope(path="/", http_version="2.0")
285+
scope["headers"] = [(b"some_header", b"1")]
286+
request = ASGIRequest(scope, None)
287+
# No form of the header exists anywhere.
288+
self.assertNotIn("Some_Header", request.headers)
289+
self.assertNotIn("Some-Header", request.headers)
290+
self.assertNotIn("SOME_HEADER", request.META)
291+
self.assertNotIn("SOME-HEADER", request.META)
292+
self.assertNotIn("HTTP_SOME_HEADER", request.META)
293+
283294
async def test_cancel_post_request_with_sync_processing(self):
284295
"""
285296
The request.body object should be available and readable in view

0 commit comments

Comments
 (0)