AIOHTTP's Unicode processing of header values could cause parsing discrepancies
Description
AIOHTTP is an asynchronous HTTP client/server framework for asyncio and Python. Versions 3.13.2 and below of the Python HTTP parser may allow a request smuggling attack with the presence of non-ASCII characters. If a pure Python version of AIOHTTP is installed (i.e. without the usual C extensions) or AIOHTTP_NO_EXTENSIONS is enabled, then an attacker may be able to execute a request smuggling attack to bypass certain firewalls or proxy protections. This issue is fixed in version 3.13.3.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
aiohttpPyPI | < 3.13.3 | 3.13.3 |
Affected products
1Patches
132677f2adfd9Reject non-ascii characters in some headers (#11886) (#11902)
3 files changed · +42 −11
aiohttp/http_parser.py+9 −7 modified@@ -238,7 +238,9 @@ def parse_headers( 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]): @@ -549,11 +551,9 @@ def parse_headers( upgrade = True # encoding - enc = headers.get(hdrs.CONTENT_ENCODING) - if enc: - enc = enc.lower() - if enc in ("gzip", "deflate", "br", "zstd"): - encoding = enc + enc = headers.get(hdrs.CONTENT_ENCODING, "") + if enc.isascii() and enc.lower() in {"gzip", "deflate", "br", "zstd"}: + encoding = enc # chunking te = headers.get(hdrs.TRANSFER_ENCODING) @@ -670,7 +670,9 @@ def parse_message(self, lines: List[bytes]) -> RawRequestMessage: ) def _is_chunked_te(self, te: str) -> bool: - if te.rsplit(",", maxsplit=1)[-1].strip(" \t").lower() == "chunked": + te = te.rsplit(",", maxsplit=1)[-1].strip(" \t") + # .lower() transforms some non-ascii chars, so must check first. + if te.isascii() and te.lower() == "chunked": return True # https://www.rfc-editor.org/rfc/rfc9112#section-6.3-2.4.3 raise BadHttpMessage("Request has invalid `Transfer-Encoding`")
aiohttp/_http_parser.pyx+3 −3 modified@@ -419,7 +419,8 @@ cdef class HttpParser: headers = CIMultiDictProxy(CIMultiDict(self._headers)) if self._cparser.type == cparser.HTTP_REQUEST: - allowed = upgrade and headers.get("upgrade", "").lower() in ALLOWED_UPGRADES + 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: @@ -434,8 +435,7 @@ 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', 'zstd'): + if enc.isascii() and enc.lower() in {"gzip", "deflate", "br", "zstd"}: encoding = enc if self._cparser.type == cparser.HTTP_REQUEST:
tests/test_http_parser.py+30 −1 modified@@ -476,7 +476,21 @@ def test_request_chunked(parser) -> None: assert isinstance(payload, streams.StreamReader) -def test_request_te_chunked_with_content_length(parser: Any) -> None: +def test_te_header_non_ascii(parser: HttpRequestParser) -> 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: HttpRequestParser) -> 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: HttpRequestParser) -> None: text = ( b"GET /test HTTP/1.1\r\n" b"content-length: 1234\r\n" @@ -574,6 +588,21 @@ def test_compression_zstd(parser: HttpRequestParser) -> None: assert msg.compression == "zstd" +@pytest.mark.parametrize( + "enc", + ( + "zstd".encode(), # "st".upper() == "ST" + "deflate".encode(), # "fl".upper() == "FL" + ), +) +def test_compression_non_ascii(parser: HttpRequestParser, 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: HttpRequestParser) -> None: text = b"GET /test HTTP/1.1\r\ncontent-encoding: compress\r\n\r\n" messages, upgrade, tail = parser.feed_data(text)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-69f9-5gxw-wvc2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-69224ghsaADVISORY
- github.com/aio-libs/aiohttp/commit/32677f2adfd907420c078dda6b79225c6f4ebce0ghsax_refsource_MISCWEB
- github.com/aio-libs/aiohttp/security/advisories/GHSA-69f9-5gxw-wvc2ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.