Repository: django/daphne

Issue:
Daphne allows invalid characters within header names
# The bug

RFC 9110 defines the header names must consist of a single token, which is defined as follows:
```
  token          = 1*tchar
  tchar          = "!" / "#" / "$" / "%" / "&" / "'" / "*"
                 / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
                 / DIGIT / ALPHA
                 ; any VCHAR, except delimiters
```

When Daphne receives a request with a header name containing any or all of the following characters, it does not reject the message:
```
\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\x0b\x0c\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f "(),/;<=>?@[/]{}\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef
```

Thus, when you send the following request:
```
GET / HTTP/1.1\r\n
\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\x0b\x0c\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f "(),/;<=>?@[/]{}\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef: whatever\r\n
\r\n
```
Daphne's interpretation is as follows:
```
[
    HTTPRequest(
        method=b'GET', uri=b'/', version=b'1.1',
        headers=[
            (b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\x0b\x0c\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f "(),/;<=>?@[/]{}\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef', b'whatever'),
        ],
        body=b'',
    ),
]
```
(i.e. the invalid header name made it into the request unchanged)

Nearly all other HTTP implementations reject the above request, including Uvicorn, Hypercorn, AIOHTTP, Apache httpd, Bun, CherryPy, Deno, FastHTTP, Go net/http, Gunicorn, H2O, Hyper, Jetty, libsoup, Lighttpd, Mongoose, Nginx, Node.js LiteSpeed, Passenger, Puma, Tomcat, OpenWrt uhttpd, Uvicorn, Waitress, WEBrick, and OpenBSD httpd.

## Your OS and runtime environment:
```
$ uname -a
Linux de8a63fbb2f4 6.7.2-arch1-2 #1 SMP PREEMPT_DYNAMIC Wed, 31 Jan 2024 09:22:15 +0000 x86_64 GNU/Linux
```

## `pip freeze`:
```
asgiref==3.7.2
attrs==23.2.0
autobahn==23.6.2
Automat==22.10.0
cffi==1.16.0
constantly==23.10.4
cryptography==42.0.2
Cython==0.29.32
daphne @ file:///app/daphne
h2==4.1.0
hpack==4.0.0
hyperframe==6.0.1
hyperlink==21.0.0
idna==3.6
incremental==22.10.0
priority==1.3.0
pyasn1==0.5.1
pyasn1-modules==0.3.0
pycparser==2.21
Pygments==2.14.0
pyOpenSSL==24.0.0
python-afl==0.7.4
PyYAML==6.0
service-identity==24.1.0
six==1.16.0
Twisted==23.10.0
txaio==23.1.1
typing_extensions==4.9.0
zope.interface==6.1
```
(Using Daphne built from main, with the latest commit being https://github.com/django/daphne/commit/993efe62ce9e9c1cd64c81b6ee7dfa7b819482c7 at the time of writing)

## How you're running Channels (runserver? daphne/runworker? Nginx/Apache in front?)
Directly invoking Daphne from the command line.

## Console logs and full tracebacks of any errors
N/A

Comments:

Patch:
diff --git a/daphne/http_protocol.py b/daphne/http_protocol.py
index f0657fd..b7da1bf 100755
--- a/daphne/http_protocol.py
+++ b/daphne/http_protocol.py
@@ -9,7 +9,7 @@ from twisted.protocols.policies import ProtocolWrapper
 from twisted.web import http
 from zope.interface import implementer
 
-from .utils import parse_x_forwarded_for
+from .utils import HEADER_NAME_RE, parse_x_forwarded_for
 
 logger = logging.getLogger(__name__)
 
@@ -69,6 +69,13 @@ class WebRequest(http.Request):
     def process(self):
         try:
             self.request_start = time.time()
+
+            # Validate header names.
+            for name, _ in self.requestHeaders.getAllRawHeaders():
+                if not HEADER_NAME_RE.fullmatch(name):
+                    self.basic_error(400, b"Bad Request", "Invalid header name")
+                    return
+
             # Get upgrade header
             upgrade_header = None
             if self.requestHeaders.hasHeader(b"Upgrade"):
diff --git a/daphne/utils.py b/daphne/utils.py
index 81f1f9d..0699314 100644
--- a/daphne/utils.py
+++ b/daphne/utils.py
@@ -1,7 +1,12 @@
 import importlib
+import re
 
 from twisted.web.http_headers import Headers
 
+# Header name regex as per h11.
+# https://github.com/python-hyper/h11/blob/a2c68948accadc3876dffcf979d98002e4a4ed27/h11/_abnf.py#L10-L21
+HEADER_NAME_RE = re.compile(rb"[-!#$%&'*+.^_`|~0-9a-zA-Z]+")
+
 
 def import_by_path(path):
     """


Test patch:
diff --git a/tests/test_http_request.py b/tests/test_http_request.py
index 52f6dd1..dca5bd7 100644
--- a/tests/test_http_request.py
+++ b/tests/test_http_request.py
@@ -310,3 +310,15 @@ class TestHTTPRequest(DaphneTestCase):
             b"GET /?\xc3\xa4\xc3\xb6\xc3\xbc HTTP/1.0\r\n\r\n"
         )
         self.assertTrue(response.startswith(b"HTTP/1.0 400 Bad Request"))
+
+    def test_invalid_header_name(self):
+        """
+        Tests that requests with invalid header names fail.
+        """
+        # Test cases follow those used by h11
+        # https://github.com/python-hyper/h11/blob/a2c68948accadc3876dffcf979d98002e4a4ed27/h11/tests/test_headers.py#L24-L35
+        for header_name in [b"foo bar", b"foo\x00bar", b"foo\xffbar", b"foo\x01bar"]:
+            response = self.run_daphne_raw(
+                f"GET / HTTP/1.0\r\n{header_name}: baz\r\n\r\n".encode("ascii")
+            )
+            self.assertTrue(response.startswith(b"HTTP/1.0 400 Bad Request"))