From 4412731aa64d62a6dd7edae79e0c15b72666d7ca Mon Sep 17 00:00:00 2001
From: Jacob Walls <jacobtylerwalls@gmail.com>
Date: Thu, 22 Jan 2026 17:01:46 -0500
Subject: [PATCH] [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.
---
 django/core/handlers/asgi.py |  3 +++
 django/test/client.py        |  5 ++++-
 docs/releases/4.2.30.txt     | 20 ++++++++++++++++++++
 tests/asgi/tests.py          | 11 +++++++++++
 4 files changed, 38 insertions(+), 1 deletion(-)

diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py
index d9518231180d..3b10a59ec73b 100644
--- a/django/core/handlers/asgi.py
+++ b/django/core/handlers/asgi.py
@@ -85,6 +85,9 @@ def __init__(self, scope, body_file):
         _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 cf63265faa92..a465cc98f55b 100644
--- a/django/test/client.py
+++ b/django/test/client.py
@@ -705,7 +705,10 @@ def generic(
         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/tests/asgi/tests.py b/tests/asgi/tests.py
index 9395c8626cac..bf623c208389 100644
--- a/tests/asgi/tests.py
+++ b/tests/asgi/tests.py
@@ -220,6 +220,17 @@ def META(self, value):
         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/")
