From 661f91935d061a3d7d554f2c808c8a14f3122991 Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Wed, 11 Mar 2026 20:38:17 -0300 Subject: [PATCH] [PR #12231/7043bc56 backport][3.13] Adjust header value character checks to RFC 9110 (#12235) CVE: CVE-2026-34520 Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/9370b9714a7a56003cacd31a9b4ae16eab109ba4] Backport Changes: - Included the C-parser null-byte validation from upstream prerequisite commit db560cfeab32aa35f3d06f7a24238bedc01ffe2b because the Scarthgap 3.9.5 recipe patch stack does not contain that prerequisite. - Used the target's local `raw_value` bytes object when raising `InvalidHeader`, since aiohttp 3.9.5 converts `_raw_value` before checking. - Added a response-parser regression test for the C-parser validation. - Omitted the generated aiohttp/_http_parser.c changes because the Scarthgap recipe regenerates this file from _http_parser.pyx with Cython. Co-authored-by: rodrigo.nogueira (cherry picked from commit 9370b9714a7a56003cacd31a9b4ae16eab109ba4) Signed-off-by: Darsh Kelaiya --- CHANGES/12231.bugfix.rst | 2 ++ aiohttp/_http_parser.pyx | 7 +++++++ aiohttp/http_parser.py | 9 ++++++++- tests/test_http_parser.py | 17 +++++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 CHANGES/12231.bugfix.rst diff --git a/CHANGES/12231.bugfix.rst b/CHANGES/12231.bugfix.rst new file mode 100644 index 000000000..cd74bd1e7 --- /dev/null +++ b/CHANGES/12231.bugfix.rst @@ -0,0 +1,2 @@ +Adjusted pure-Python request header value validation to align with RFC 9110 control-character handling, while preserving lax response parser behavior, and added regression tests for Host/header control-character cases. +-- by :user:`rodrigobnogueira`. diff --git a/aiohttp/_http_parser.pyx b/aiohttp/_http_parser.pyx index cc8b13cfb..496498d6f 100644 --- a/aiohttp/_http_parser.pyx +++ b/aiohttp/_http_parser.pyx @@ -387,5 +387,12 @@ cdef class HttpParser: value = raw_value.decode('utf-8', 'surrogateescape') + # reject null bytes in header values - matches the Python parser + # check at http_parser.py. llhttp in lenient mode doesn't reject + # these itself, so we need to catch them here. + # ref: RFC 9110 section 5.5 (CTL chars forbidden in field values) + if "\x00" in value: + raise InvalidHeader(raw_value) + self._headers.add(name, value) if len(self._headers) > self._max_headers: raise BadHttpMessage("Too many headers received") diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py index 95ac28252..a7fec7f01 100644 --- a/aiohttp/http_parser.py +++ b/aiohttp/http_parser.py @@ -73,6 +73,10 @@ ASCIISET: Final[Set[str]] = set(string.printable) # token = 1*tchar _TCHAR_SPECIALS: Final[str] = re.escape("!#$%&'*+-.^_`|~") TOKENRE: Final[Pattern[str]] = re.compile(f"[0-9A-Za-z{_TCHAR_SPECIALS}]+") +# https://www.rfc-editor.org/rfc/rfc9110#section-5.5-5 +_FIELD_VALUE_FORBIDDEN_CTL_RE: Final[Pattern[str]] = re.compile( + r"[\x00-\x08\x0a-\x1f\x7f]" +) VERSRE: Final[Pattern[str]] = re.compile(r"HTTP/(\d)\.(\d)", re.ASCII) DIGITS: Final[Pattern[str]] = re.compile(r"\d+", re.ASCII) HEXDIGITS: Final[Pattern[bytes]] = re.compile(rb"[0-9a-fA-F]+") @@ -200,7 +204,10 @@ class HeadersParser: value = bvalue.decode("utf-8", "surrogateescape") # https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5-5 - if "\n" in value or "\r" in value or "\x00" in value: + if self._lax: + if "\n" in value or "\r" in value or "\x00" in value: + raise InvalidHeader(bvalue) + elif _FIELD_VALUE_FORBIDDEN_CTL_RE.search(value): raise InvalidHeader(bvalue) headers.add(name, value) diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py index cb562f598..be7446e0a 100644 --- a/tests/test_http_parser.py +++ b/tests/test_http_parser.py @@ -210,6 +210,9 @@ def test_bad_header_name(parser: Any, rfc9110_5_6_2_token_delim: str) -> None: "Foo : bar", # https://www.rfc-editor.org/rfc/rfc9112.html#section-5.1-2 "Foo\t: bar", "\xffoo: bar", + "Foo: abc\x01def", # CTL bytes forbidden per RFC 9110 ยง5.5 + "Foo: abc\x7fdef", # DEL is also a CTL byte + "Foo: abc\x1fdef", ), ) def test_bad_headers(parser: Any, hdr: str) -> None: @@ -218,6 +221,20 @@ def test_bad_headers(parser: Any, hdr: str) -> None: parser.feed_data(text) +def test_ctl_host_header_bad_characters(parser: HttpRequestParser) -> None: + """CTL byte in Host header must be rejected.""" + text = b"GET /test HTTP/1.1\r\nHost: trusted.example\x01@bad.test\r\n\r\n" + with pytest.raises(http_exceptions.BadHttpMessage): + parser.feed_data(text) + + +def test_null_byte_in_response_header_value( + response: HttpResponseParser, +) -> None: + with pytest.raises(http_exceptions.InvalidHeader): + response.feed_data(b"HTTP/1.1 200 OK\r\nFoo: abc\x00def\r\n\r\n") + + def test_unpaired_surrogate_in_header_py(loop: Any, protocol: Any) -> None: parser = HttpRequestParserPy( protocol, -- 2.35.6