Skip to content

Commit 8e6557b

Browse files
committed
Reject encoded discovery responses and bound payload copies
1 parent cffeca1 commit 8e6557b

2 files changed

Lines changed: 72 additions & 3 deletions

File tree

‎src/blosc2/caterva2_url.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,13 +144,15 @@ def discover_service(value, *, required=False, auth_token=None):
144144
auth_token = discovery_auth_token(value, required=required, auth_token=auth_token)
145145
url = _server_url(value.rstrip("/"), "api/roots")
146146
client = _sync_client()
147+
headers = dict(_auth_headers(auth_token) or {})
148+
headers["Accept-Encoding"] = "identity"
147149
deadline = time.monotonic() + 3
148150
for _ in range(4):
149151
remaining = deadline - time.monotonic()
150152
if remaining <= 0:
151153
raise TimeoutError("Caterva2 service discovery exceeded 3 seconds")
152154
with client.stream(
153-
"GET", url, headers=_auth_headers(auth_token), timeout=remaining, follow_redirects=False
155+
"GET", url, headers=headers, timeout=remaining, follow_redirects=False
154156
) as response:
155157
if response.status_code in {301, 302, 303, 307, 308}:
156158
from urllib.parse import urljoin
@@ -176,13 +178,16 @@ def discover_service(value, *, required=False, auth_token=None):
176178
response.raise_for_status()
177179
return None
178180
response.raise_for_status()
181+
# Reject before iter_bytes can decompress an untrusted response.
182+
if response.headers.get("content-encoding", "identity").strip().lower() != "identity":
183+
raise ValueError("Encoded Caterva2 roots responses are unsupported")
179184
payload = bytearray()
180185
for chunk in response.iter_bytes():
181186
if time.monotonic() > deadline:
182187
raise TimeoutError("Caterva2 service discovery exceeded 3 seconds")
183-
payload.extend(chunk)
184-
if len(payload) > 1 << 20:
188+
if len(payload) + len(chunk) > 1 << 20:
185189
raise ValueError("Caterva2 roots response exceeds 1 MiB")
190+
payload.extend(chunk)
186191
import json
187192

188193
try:

‎tests/test_caterva2_access.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,70 @@ def redirect(request):
271271
assert seen == ["https://host/demo/api/roots", "https://host/demo/api/roots/"]
272272

273273

274+
@pytest.mark.parametrize("encoding", ["gzip", "br", "deflate"])
275+
def test_discovery_rejects_encoding_before_reading(encoding, monkeypatch):
276+
import httpx
277+
278+
from blosc2.caterva2_url import discover_service
279+
280+
class UnreadableStream(httpx.SyncByteStream):
281+
def __iter__(self):
282+
pytest.fail("Encoded response was read or decompressed")
283+
yield b"" # Make this a stream iterator without reading any payload.
284+
285+
def respond(request):
286+
assert request.headers["accept-encoding"] == "identity"
287+
return httpx.Response(200, headers={"content-encoding": encoding}, stream=UnreadableStream())
288+
289+
with httpx.Client(transport=httpx.MockTransport(respond)) as client:
290+
monkeypatch.setattr(blosc2.c2array, "_sync_client", lambda: client)
291+
with pytest.raises(ValueError, match="Encoded"):
292+
discover_service("https://host")
293+
294+
295+
@pytest.mark.parametrize("fragmented", [False, True])
296+
def test_discovery_checks_size_before_copying(fragmented, monkeypatch):
297+
import httpx
298+
299+
import blosc2.caterva2_url as discovery
300+
301+
class BoundedPayload(bytearray):
302+
def extend(self, data):
303+
assert len(self) + len(data) <= 1 << 20, "Oversized fragment copied before checking"
304+
super().extend(data)
305+
306+
class Fragments(httpx.SyncByteStream):
307+
def __iter__(self):
308+
if fragmented:
309+
yield b" " * (1 << 19)
310+
yield b" " * ((1 << 19) + 1)
311+
else:
312+
yield b" " * ((1 << 20) + 1)
313+
314+
monkeypatch.setattr(discovery, "bytearray", BoundedPayload, raising=False)
315+
with httpx.Client(
316+
transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=Fragments()))
317+
) as client:
318+
monkeypatch.setattr(blosc2.c2array, "_sync_client", lambda: client)
319+
with pytest.raises(ValueError, match="exceeds 1 MiB"):
320+
discovery.discover_service("https://host")
321+
322+
323+
def test_discovery_accepts_identity_at_exact_byte_limit(monkeypatch):
324+
import httpx
325+
326+
from blosc2.caterva2_url import discover_service
327+
328+
payload = b"{}" + b" " * ((1 << 20) - 2)
329+
with httpx.Client(
330+
transport=httpx.MockTransport(
331+
lambda _: httpx.Response(200, headers={"content-encoding": "identity"}, content=payload)
332+
)
333+
) as client:
334+
monkeypatch.setattr(blosc2.c2array, "_sync_client", lambda: client)
335+
assert discover_service("https://host") == {}
336+
337+
274338
def test_repository_freezes_auth_context(caterva2_source): # noqa: F811
275339
base, _, _, stats = caterva2_source
276340
stats["roots"]["@broken"] = {"name": "@broken"}

0 commit comments

Comments
 (0)