From 0190de0d72ea5eac0870fa6eb61f33bf53a475d7 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 6 Jun 2026 11:26:08 +0200 Subject: [PATCH 1/5] Limit chained Content-Encoding decoders to 5 --- src/httpx2/CHANGELOG.md | 6 ++++++ src/httpx2/httpx2/_decoders.py | 4 ++++ tests/httpx2/test_decoders.py | 12 ++++++++++++ 3 files changed, 22 insertions(+) diff --git a/src/httpx2/CHANGELOG.md b/src/httpx2/CHANGELOG.md index 51e24cee..62c1fdad 100644 --- a/src/httpx2/CHANGELOG.md +++ b/src/httpx2/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## Unreleased + +### Changed + +* Limit the number of chained `Content-Encoding` decoders to 5. + ## 2.3.0 (June 1st, 2026) ### Changed diff --git a/src/httpx2/httpx2/_decoders.py b/src/httpx2/httpx2/_decoders.py index b8b1bb29..6ee5afe1 100644 --- a/src/httpx2/httpx2/_decoders.py +++ b/src/httpx2/httpx2/_decoders.py @@ -230,11 +230,15 @@ class MultiDecoder(ContentDecoder): Handle the case where multiple encodings have been applied. """ + max_decode_links: typing.ClassVar[int] = 5 + def __init__(self, children: typing.Sequence[ContentDecoder]) -> None: """ 'children' should be a sequence of decoders in the order in which each was applied. """ + if len(children) > self.max_decode_links: + raise DecodingError(f"Too many content encodings in the chain: {len(children)} > {self.max_decode_links}") # Note that we reverse the order for decoding. self.children = list(reversed(children)) diff --git a/tests/httpx2/test_decoders.py b/tests/httpx2/test_decoders.py index 2f137f7f..8d153458 100644 --- a/tests/httpx2/test_decoders.py +++ b/tests/httpx2/test_decoders.py @@ -217,6 +217,18 @@ def test_multi_with_identity() -> None: assert response.content == body +def test_multi_decode_links_limit() -> None: + body = b"test 123" + compressed_body = body + for _ in range(6): + compressor = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS | 16) + compressed_body = compressor.compress(compressed_body) + compressor.flush() + + headers = [(b"Content-Encoding", b", ".join([b"gzip"] * 6))] + with pytest.raises(httpx2.DecodingError, match="Too many content encodings"): + httpx2.Response(200, headers=headers, content=compressed_body) + + @pytest.mark.anyio async def test_streaming() -> None: body = b"test 123" From fed4375115635b350675e6a12ebd68377c128a5a Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 6 Jun 2026 11:28:42 +0200 Subject: [PATCH 2/5] Reword Content-Encoding chain limit error message --- src/httpx2/httpx2/_decoders.py | 2 +- tests/httpx2/test_decoders.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/httpx2/httpx2/_decoders.py b/src/httpx2/httpx2/_decoders.py index 6ee5afe1..44b640d9 100644 --- a/src/httpx2/httpx2/_decoders.py +++ b/src/httpx2/httpx2/_decoders.py @@ -238,7 +238,7 @@ def __init__(self, children: typing.Sequence[ContentDecoder]) -> None: each was applied. """ if len(children) > self.max_decode_links: - raise DecodingError(f"Too many content encodings in the chain: {len(children)} > {self.max_decode_links}") + raise DecodingError(f"Cannot apply more than {self.max_decode_links} content encodings.") # Note that we reverse the order for decoding. self.children = list(reversed(children)) diff --git a/tests/httpx2/test_decoders.py b/tests/httpx2/test_decoders.py index 8d153458..b678f939 100644 --- a/tests/httpx2/test_decoders.py +++ b/tests/httpx2/test_decoders.py @@ -225,7 +225,7 @@ def test_multi_decode_links_limit() -> None: compressed_body = compressor.compress(compressed_body) + compressor.flush() headers = [(b"Content-Encoding", b", ".join([b"gzip"] * 6))] - with pytest.raises(httpx2.DecodingError, match="Too many content encodings"): + with pytest.raises(httpx2.DecodingError, match="Cannot apply more than 5 content encodings"): httpx2.Response(200, headers=headers, content=compressed_body) From ef171fca6e11d6bdf52c1cc7d7ec03d7a72fecbe Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 6 Jun 2026 11:34:51 +0200 Subject: [PATCH 3/5] Enforce Content-Encoding chain limit before building decoders --- src/httpx2/httpx2/_decoders.py | 2 -- src/httpx2/httpx2/_models.py | 6 +++++- tests/httpx2/test_decoders.py | 8 +------- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/httpx2/httpx2/_decoders.py b/src/httpx2/httpx2/_decoders.py index 44b640d9..fce14d86 100644 --- a/src/httpx2/httpx2/_decoders.py +++ b/src/httpx2/httpx2/_decoders.py @@ -237,8 +237,6 @@ def __init__(self, children: typing.Sequence[ContentDecoder]) -> None: 'children' should be a sequence of decoders in the order in which each was applied. """ - if len(children) > self.max_decode_links: - raise DecodingError(f"Cannot apply more than {self.max_decode_links} content encodings.") # Note that we reverse the order for decoding. self.children = list(reversed(children)) diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index 834f0806..eb7cb77f 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -23,6 +23,7 @@ ) from ._exceptions import ( CookieConflict, + DecodingError, HTTPStatusError, RequestNotRead, ResponseNotRead, @@ -679,8 +680,11 @@ def _get_content_decoder(self) -> ContentDecoder: content, depending on the Content-Encoding used in the response. """ if not hasattr(self, "_decoder"): - decoders: list[ContentDecoder] = [] values = self.headers.get_list("content-encoding", split_commas=True) + if len(values) > MultiDecoder.max_decode_links: + raise DecodingError(f"Cannot apply more than {MultiDecoder.max_decode_links} content encodings.") + + decoders: list[ContentDecoder] = [] for value in values: value = value.strip().lower() try: diff --git a/tests/httpx2/test_decoders.py b/tests/httpx2/test_decoders.py index b678f939..93b11c82 100644 --- a/tests/httpx2/test_decoders.py +++ b/tests/httpx2/test_decoders.py @@ -218,15 +218,9 @@ def test_multi_with_identity() -> None: def test_multi_decode_links_limit() -> None: - body = b"test 123" - compressed_body = body - for _ in range(6): - compressor = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS | 16) - compressed_body = compressor.compress(compressed_body) + compressor.flush() - headers = [(b"Content-Encoding", b", ".join([b"gzip"] * 6))] with pytest.raises(httpx2.DecodingError, match="Cannot apply more than 5 content encodings"): - httpx2.Response(200, headers=headers, content=compressed_body) + httpx2.Response(200, headers=headers, content=b"") @pytest.mark.anyio From 33748b7e5b585ec4c38a60a0f0726084172d1ac2 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 6 Jun 2026 12:36:34 +0200 Subject: [PATCH 4/5] Move Content-Encoding chain limit into MultiDecoder --- src/httpx2/httpx2/_decoders.py | 7 +++++-- src/httpx2/httpx2/_models.py | 23 ++--------------------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/src/httpx2/httpx2/_decoders.py b/src/httpx2/httpx2/_decoders.py index fce14d86..1087a906 100644 --- a/src/httpx2/httpx2/_decoders.py +++ b/src/httpx2/httpx2/_decoders.py @@ -232,11 +232,14 @@ class MultiDecoder(ContentDecoder): max_decode_links: typing.ClassVar[int] = 5 - def __init__(self, children: typing.Sequence[ContentDecoder]) -> None: + def __init__(self, encodings: typing.Sequence[str]) -> None: """ - 'children' should be a sequence of decoders in the order in which + 'encodings' should be the content codings in the order in which each was applied. """ + if len(encodings) > self.max_decode_links: + raise DecodingError(f"Cannot apply more than {self.max_decode_links} content encodings.") + children = [SUPPORTED_DECODERS[encoding]() for encoding in encodings if encoding in SUPPORTED_DECODERS] # Note that we reverse the order for decoding. self.children = list(reversed(children)) diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index eb7cb77f..1cf45fba 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -12,10 +12,8 @@ from ._content import ByteStream, UnattachedStream, encode_request, encode_response from ._decoders import ( - SUPPORTED_DECODERS, ByteChunker, ContentDecoder, - IdentityDecoder, LineDecoder, MultiDecoder, TextChunker, @@ -23,7 +21,6 @@ ) from ._exceptions import ( CookieConflict, - DecodingError, HTTPStatusError, RequestNotRead, ResponseNotRead, @@ -681,24 +678,8 @@ def _get_content_decoder(self) -> ContentDecoder: """ if not hasattr(self, "_decoder"): values = self.headers.get_list("content-encoding", split_commas=True) - if len(values) > MultiDecoder.max_decode_links: - raise DecodingError(f"Cannot apply more than {MultiDecoder.max_decode_links} content encodings.") - - decoders: list[ContentDecoder] = [] - for value in values: - value = value.strip().lower() - try: - decoder_cls = SUPPORTED_DECODERS[value] - decoders.append(decoder_cls()) - except KeyError: - continue - - if len(decoders) == 1: - self._decoder = decoders[0] - elif len(decoders) > 1: - self._decoder = MultiDecoder(children=decoders) - else: - self._decoder = IdentityDecoder() + encodings = [value.strip().lower() for value in values] + self._decoder = MultiDecoder(encodings) return self._decoder From dea9472af2393b19d0bdf0e0f8d4eddfc438dc12 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 6 Jun 2026 12:48:52 +0200 Subject: [PATCH 5/5] Preserve single-decoder path when applying chain limit --- src/httpx2/httpx2/_models.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index 1cf45fba..314342bd 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -14,6 +14,7 @@ from ._decoders import ( ByteChunker, ContentDecoder, + IdentityDecoder, LineDecoder, MultiDecoder, TextChunker, @@ -679,7 +680,13 @@ def _get_content_decoder(self) -> ContentDecoder: if not hasattr(self, "_decoder"): values = self.headers.get_list("content-encoding", split_commas=True) encodings = [value.strip().lower() for value in values] - self._decoder = MultiDecoder(encodings) + decoder = MultiDecoder(encodings) + if len(decoder.children) == 1: + self._decoder = decoder.children[0] + elif decoder.children: + self._decoder = decoder + else: + self._decoder = IdentityDecoder() return self._decoder