From 3b3752eb90c2782aec7b054f68569f2fef794597 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sun, 19 Apr 2026 11:38:05 +0200 Subject: [PATCH 1/2] test: add failing fixtures for forbidden trailer fields --- .../invalid/rfc9110_trailer_forbidden_cl_01.http | 9 +++++++++ .../invalid/rfc9110_trailer_forbidden_cl_01.py | 9 +++++++++ .../invalid/rfc9110_trailer_forbidden_host_01.http | 9 +++++++++ .../invalid/rfc9110_trailer_forbidden_host_01.py | 11 +++++++++++ .../invalid/rfc9110_trailer_forbidden_te_01.http | 9 +++++++++ .../invalid/rfc9110_trailer_forbidden_te_01.py | 9 +++++++++ 6 files changed, 56 insertions(+) create mode 100644 tests/requests/invalid/rfc9110_trailer_forbidden_cl_01.http create mode 100644 tests/requests/invalid/rfc9110_trailer_forbidden_cl_01.py create mode 100644 tests/requests/invalid/rfc9110_trailer_forbidden_host_01.http create mode 100644 tests/requests/invalid/rfc9110_trailer_forbidden_host_01.py create mode 100644 tests/requests/invalid/rfc9110_trailer_forbidden_te_01.http create mode 100644 tests/requests/invalid/rfc9110_trailer_forbidden_te_01.py diff --git a/tests/requests/invalid/rfc9110_trailer_forbidden_cl_01.http b/tests/requests/invalid/rfc9110_trailer_forbidden_cl_01.http new file mode 100644 index 00000000..7562e1a8 --- /dev/null +++ b/tests/requests/invalid/rfc9110_trailer_forbidden_cl_01.http @@ -0,0 +1,9 @@ +POST /p HTTP/1.1\r\n +Host: example.com\r\n +Transfer-Encoding: chunked\r\n +\r\n +5\r\n +hello\r\n +0\r\n +Content-Length: 99\r\n +\r\n diff --git a/tests/requests/invalid/rfc9110_trailer_forbidden_cl_01.py b/tests/requests/invalid/rfc9110_trailer_forbidden_cl_01.py new file mode 100644 index 00000000..b0f8ccae --- /dev/null +++ b/tests/requests/invalid/rfc9110_trailer_forbidden_cl_01.py @@ -0,0 +1,9 @@ +# +# This file is part of gunicorn released under the MIT license. +# See the NOTICE for more information. + +# RFC 9110 section 6.5.1: Content-Length in trailers is a classic +# smuggling vector; origin must reject. +from gunicorn.http.errors import InvalidHeaderName +request = InvalidHeaderName +python_only = True diff --git a/tests/requests/invalid/rfc9110_trailer_forbidden_host_01.http b/tests/requests/invalid/rfc9110_trailer_forbidden_host_01.http new file mode 100644 index 00000000..348082e7 --- /dev/null +++ b/tests/requests/invalid/rfc9110_trailer_forbidden_host_01.http @@ -0,0 +1,9 @@ +POST /p HTTP/1.1\r\n +Host: example.com\r\n +Transfer-Encoding: chunked\r\n +\r\n +5\r\n +hello\r\n +0\r\n +Host: evil.example.com\r\n +\r\n diff --git a/tests/requests/invalid/rfc9110_trailer_forbidden_host_01.py b/tests/requests/invalid/rfc9110_trailer_forbidden_host_01.py new file mode 100644 index 00000000..fe897329 --- /dev/null +++ b/tests/requests/invalid/rfc9110_trailer_forbidden_host_01.py @@ -0,0 +1,11 @@ +# +# This file is part of gunicorn released under the MIT license. +# See the NOTICE for more information. + +# RFC 9110 section 6.5.1: certain header fields must not be sent in +# trailers because they alter routing or message framing (e.g. Host, +# Content-Length, Transfer-Encoding). Accepting them enables smuggling. +from gunicorn.http.errors import InvalidHeaderName +request = InvalidHeaderName +# The C parser (gunicorn_h1c) does not yet enforce this rule. +python_only = True diff --git a/tests/requests/invalid/rfc9110_trailer_forbidden_te_01.http b/tests/requests/invalid/rfc9110_trailer_forbidden_te_01.http new file mode 100644 index 00000000..e5c98d16 --- /dev/null +++ b/tests/requests/invalid/rfc9110_trailer_forbidden_te_01.http @@ -0,0 +1,9 @@ +POST /p HTTP/1.1\r\n +Host: example.com\r\n +Transfer-Encoding: chunked\r\n +\r\n +5\r\n +hello\r\n +0\r\n +Transfer-Encoding: chunked\r\n +\r\n diff --git a/tests/requests/invalid/rfc9110_trailer_forbidden_te_01.py b/tests/requests/invalid/rfc9110_trailer_forbidden_te_01.py new file mode 100644 index 00000000..caa2f5ac --- /dev/null +++ b/tests/requests/invalid/rfc9110_trailer_forbidden_te_01.py @@ -0,0 +1,9 @@ +# +# This file is part of gunicorn released under the MIT license. +# See the NOTICE for more information. + +# RFC 9110 section 6.5.1: Transfer-Encoding in trailers alters framing +# and must not be accepted. +from gunicorn.http.errors import InvalidHeaderName +request = InvalidHeaderName +python_only = True From a9270e3f9aceee307b1757fbe643970f5fc84d1b Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sun, 19 Apr 2026 11:41:00 +0200 Subject: [PATCH 2/2] fix: reject forbidden trailer field-names (RFC 9110 section 6.5.1) Host, Content-Length, Transfer-Encoding, Trailer, Authorization, and TE are not allowed in trailer sections; accepting them enables smuggling and routing confusion. Both WSGI and ASGI Python parsers now raise InvalidHeaderName when any of these appears in a trailer. --- gunicorn/asgi/parser.py | 20 ++++++++++++++++++++ gunicorn/http/message.py | 16 ++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/gunicorn/asgi/parser.py b/gunicorn/asgi/parser.py index 3f4c8bea..9e3f70f2 100644 --- a/gunicorn/asgi/parser.py +++ b/gunicorn/asgi/parser.py @@ -29,6 +29,18 @@ class InvalidProxyHeader(ParseError): PP_V2_SIGNATURE = b"\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A" +# RFC 9110 section 6.5.1: fields forbidden in trailers because they alter +# routing, framing, or authentication. +RFC9110_6_5_1_FORBIDDEN_TRAILER = frozenset(( + b"host", + b"content-length", + b"transfer-encoding", + b"trailer", + b"authorization", + b"te", +)) + + class PPCommand(IntEnum): """PROXY protocol v2 commands.""" LOCAL = 0x0 @@ -756,6 +768,14 @@ class PythonProtocol: self._on_message_complete() return True + # RFC 9110 section 6.5.1: reject fields that must not appear + # in trailers. + colon = line.find(b':') + if colon > 0: + name = line[:colon].strip(b' \t').lower() + if name in RFC9110_6_5_1_FORBIDDEN_TRAILER: + raise InvalidHeaderName(name.decode('latin-1')) + return False def _is_valid_method(self, method): diff --git a/gunicorn/http/message.py b/gunicorn/http/message.py index 7e835f9f..b27f40a5 100644 --- a/gunicorn/http/message.py +++ b/gunicorn/http/message.py @@ -132,6 +132,18 @@ METHOD_BADCHAR_RE = re.compile("[a-z#]") VERSION_RE = re.compile(r"HTTP/(\d)\.(\d)") RFC9110_5_5_INVALID_AND_DANGEROUS = re.compile(r"[\0\r\n]") +# RFC 9110 section 6.5.1: fields forbidden in trailers because they alter +# routing, framing, or authentication. Using the uppercased names stored +# by parse_headers. +RFC9110_6_5_1_FORBIDDEN_TRAILER = frozenset(( + "HOST", + "CONTENT-LENGTH", + "TRANSFER-ENCODING", + "TRAILER", + "AUTHORIZATION", + "TE", +)) + def _ip_in_allow_list(ip_str, allow_list, networks): """Check if IP address is in the allow list. @@ -235,6 +247,10 @@ class Message: # b"\xDF".decode("latin-1").upper().encode("ascii") == b"SS" name = name.upper() + # RFC 9110 section 6.5.1 + if from_trailer and name in RFC9110_6_5_1_FORBIDDEN_TRAILER: + raise InvalidHeaderName(name) + value = [value.strip(" \t")] # Consume value continuation lines..