From 4c27b675ef3d1c5b3b0f379525be575ec0d8d15e Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sat, 3 Jan 2026 00:02:45 +0000 Subject: [PATCH] Reject non-ascii characters in some headers (#11886) (#11902) CVE: CVE-2025-69224 Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/32677f2adfd907420c078dda6b79225c6f4ebce0] Backport Changes: - Adapted the pure-Python `Transfer-Encoding` validation inline because aiohttp 3.9.5 lacks the upstream `_is_chunked_te()` call path. - Backported the request/response upgrade distinction needed to apply the non-ASCII request-header check while preserving CONNECT and HTTP 101 response handling. - Added the `ALLOWED_UPGRADES` definition from upstream prerequisite commit c99a1e27375285149ea82cbdcc2f2c40e57596dc because aiohttp 3.9.5 predates it and the Cython parser otherwise fails to compile. - Retained aiohttp 3.9.5's supported `gzip`, `deflate`, and `br` content encodings; omitted upstream zstd-specific code and test context because this version lacks zstd decompression support. - Retained target-compatible `Any` test annotations because the older test module does not import `HttpRequestParser`. (cherry picked from commit 5affd64f86d28a16a8f8e6fea2d217c99bf7831f) (cherry picked from commit 32677f2adfd907420c078dda6b79225c6f4ebce0) Signed-off-by: Darsh Kelaiya --- aiohttp/_http_parser.pyx | 18 +++++++++++++----- aiohttp/http_parser.py | 8 +++++--- tests/test_http_parser.py | 32 ++++++++++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/aiohttp/_http_parser.pyx b/aiohttp/_http_parser.pyx index 7ea9b32ca..f1c130395 100644 --- a/aiohttp/_http_parser.pyx +++ b/aiohttp/_http_parser.pyx @@ -47,6 +47,7 @@ include "_headers.pxi" from aiohttp cimport _find_header +ALLOWED_UPGRADES = frozenset({"websocket"}) DEF DEFAULT_FREELIST_SIZE = 250 cdef extern from "Python.h": @@ -425,8 +426,14 @@ cdef class HttpParser: raw_headers = tuple(self._raw_headers) headers = CIMultiDictProxy(self._headers) - if upgrade or self._cparser.method == cparser.HTTP_CONNECT: - self._upgraded = True + if self._cparser.type == cparser.HTTP_REQUEST: + h_upg = headers.get("upgrade", "") + allowed = upgrade and h_upg.isascii() and h_upg.lower() in ALLOWED_UPGRADES + if allowed or self._cparser.method == cparser.HTTP_CONNECT: + self._upgraded = True + else: + if upgrade and self._cparser.status_code == 101: + self._upgraded = True # do not support old websocket spec if SEC_WEBSOCKET_KEY1 in headers: @@ -436,9 +443,10 @@ cdef class HttpParser: enc = self._content_encoding if enc is not None: self._content_encoding = None - enc = enc.lower() - if enc in ('gzip', 'deflate', 'br'): - encoding = enc + if enc.isascii(): + enc = enc.lower() + if enc in ('gzip', 'deflate', 'br'): + encoding = enc if self._cparser.type == cparser.HTTP_REQUEST: msg = _new_request_message( diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py index 0a80c5c6d..5768bd623 100644 --- a/aiohttp/http_parser.py +++ b/aiohttp/http_parser.py @@ -232,7 +232,9 @@ class HeadersParser: def _is_supported_upgrade(headers: CIMultiDictProxy[str]) -> bool: """Check if the upgrade header is supported.""" - return headers.get(hdrs.UPGRADE, "").lower() in {"tcp", "websocket"} + u = headers.get(hdrs.UPGRADE, "") + # .lower() can transform non-ascii characters. + return u.isascii() and u.lower() in {"tcp", "websocket"} class HttpParser(abc.ABC, Generic[_MsgT]): @@ -542,7 +544,7 @@ class HttpParser(abc.ABC, Generic[_MsgT]): # encoding enc = headers.get(hdrs.CONTENT_ENCODING) - if enc: + if enc and enc.isascii(): enc = enc.lower() if enc in ("gzip", "deflate", "br"): encoding = enc @@ -550,7 +552,7 @@ class HttpParser(abc.ABC, Generic[_MsgT]): # chunking te = headers.get(hdrs.TRANSFER_ENCODING) if te is not None: - if "chunked" == te.lower(): + if te.isascii() and "chunked" == te.lower(): chunked = True else: raise BadHttpMessage("Request has invalid `Transfer-Encoding`") diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py index 2f34f0bc0..021b6e4ae 100644 --- a/tests/test_http_parser.py +++ b/tests/test_http_parser.py @@ -482,6 +482,20 @@ def test_request_chunked(parser) -> None: assert isinstance(payload, streams.StreamReader) +def test_te_header_non_ascii(parser: Any) -> None: + # K = Kelvin sign, not valid ascii. + text = "GET /test HTTP/1.1\r\nTransfer-Encoding: chunKed\r\n\r\n" + with pytest.raises(http_exceptions.BadHttpMessage): + parser.feed_data(text.encode()) + + +def test_upgrade_header_non_ascii(parser: Any) -> None: + # K = Kelvin sign, not valid ascii. + text = "GET /test HTTP/1.1\r\nUpgrade: websocKet\r\n\r\n" + messages, upgrade, tail = parser.feed_data(text.encode()) + assert not upgrade + + def test_request_te_chunked_with_content_length(parser: Any) -> None: text = ( b"GET /test HTTP/1.1\r\n" @@ -555,8 +569,22 @@ def test_compression_brotli(parser) -> None: assert msg.compression == "br" -def test_compression_unknown(parser) -> None: - text = b"GET /test HTTP/1.1\r\n" b"content-encoding: compress\r\n\r\n" +@pytest.mark.parametrize( + "enc", + ( + "deflate".encode(), # "fl".upper() == "FL" + ), +) +def test_compression_non_ascii(parser: Any, enc: bytes) -> None: + text = b"GET /test HTTP/1.1\r\ncontent-encoding: " + enc + b"\r\n\r\n" + messages, upgrade, tail = parser.feed_data(text) + msg = messages[0][0] + # Non-ascii input should not evaluate to a valid encoding scheme. + assert msg.compression is None + + +def test_compression_unknown(parser: Any) -> None: + text = b"GET /test HTTP/1.1\r\ncontent-encoding: compress\r\n\r\n" messages, upgrade, tail = parser.feed_data(text) msg = messages[0][0] assert msg.compression is None -- 2.35.6