From 8e0d4b40bf2c926d410c0f191efcd296de460075 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:24:05 +0900 Subject: [PATCH 1/4] test(response): reject polymorphic response chunks --- tests/test_response_stream_exact_bytes.py | 87 +++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/test_response_stream_exact_bytes.py diff --git a/tests/test_response_stream_exact_bytes.py b/tests/test_response_stream_exact_bytes.py new file mode 100644 index 0000000..ea6bda0 --- /dev/null +++ b/tests/test_response_stream_exact_bytes.py @@ -0,0 +1,87 @@ +"""Regressions for exact response-stream chunk accounting.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator + +import httpx +import pytest + +from egressweave import EgressNotAllowedError +from egressweave.response_safety import ( + _BoundedAsyncResponseStream, + _BoundedSyncResponseStream, +) + + +class _LyingBytes(bytes): + """Expose bytes while hiding their real size from polymorphic accounting.""" + + def __len__(self) -> int: + """Return a false zero length if the wrapper trusts subclass behavior.""" + return 0 + + +class _SyncResponseSource(httpx.SyncByteStream): + """Yield one configured response chunk and record deterministic cleanup.""" + + def __init__(self, chunk: object) -> None: + """Store the dependency-controlled response chunk.""" + self.chunk = chunk + self.closed = False + + def __iter__(self) -> Iterator[bytes]: + """Yield the configured object despite the static bytes contract.""" + yield self.chunk # type: ignore[misc] + + def close(self) -> None: + """Record that the rejected dependency stream was released.""" + self.closed = True + + +class _AsyncResponseSource(httpx.AsyncByteStream): + """Yield one configured async response chunk and record cleanup.""" + + def __init__(self, chunk: object) -> None: + """Store the dependency-controlled response chunk.""" + self.chunk = chunk + self.closed = False + + async def __aiter__(self) -> AsyncIterator[bytes]: + """Yield the configured object despite the static bytes contract.""" + yield self.chunk # type: ignore[misc] + + async def aclose(self) -> None: + """Record that the rejected dependency stream was released.""" + self.closed = True + + +def _assert_clean_denial(error: EgressNotAllowedError) -> None: + """Require the stable non-leaking response policy denial.""" + assert str(error) == "egress URL is not allowed" + assert error.__cause__ is None + assert error.__context__ is None + + +def test_sync_response_rejects_bytes_subclass_before_length_accounting() -> None: + """Prevent a response bytes subclass from under-reporting its body size.""" + source = _SyncResponseSource(_LyingBytes(b"0123456789")) + stream = _BoundedSyncResponseStream(source, max_response_bytes=4) + + with pytest.raises(EgressNotAllowedError) as caught: + next(iter(stream)) + + _assert_clean_denial(caught.value) + assert source.closed is True + + +async def test_async_response_rejects_bytes_subclass_before_length_accounting() -> None: + """Apply exact response-chunk accounting to the asynchronous boundary.""" + source = _AsyncResponseSource(_LyingBytes(b"0123456789")) + stream = _BoundedAsyncResponseStream(source, max_response_bytes=4) + + with pytest.raises(EgressNotAllowedError) as caught: + await anext(stream.__aiter__()) + + _assert_clean_denial(caught.value) + assert source.closed is True From 01019e7754feb1b30b333450da5be9b53fbc9ffe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:27:56 +0900 Subject: [PATCH 2/4] fix(response): reject polymorphic stream chunks --- src/egressweave/response_safety.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/egressweave/response_safety.py b/src/egressweave/response_safety.py index 2d86bd9..dc31ffd 100644 --- a/src/egressweave/response_safety.py +++ b/src/egressweave/response_safety.py @@ -6,8 +6,11 @@ capacity. EgressWeave bounds decoded response-header fields and their name/value bytes, requests identity coding, rejects a body-bearing response that nevertheless applies a content coding, rejects unsafe declared lengths before caller-visible -delivery, and counts every transfer-decoded body byte while it is consumed. The -identity-coding invariant prevents decompression expansion outside the byte budget. +delivery, and counts every transfer-decoded body byte while it is consumed. Only +exact built-in ``bytes`` chunks are accepted before that accounting, so a private +dependency shape cannot under-report body size through polymorphic length behavior. +The identity-coding invariant prevents decompression expansion outside the byte +budget. """ from __future__ import annotations @@ -228,7 +231,7 @@ def _enforce_declared_response_size( class _BoundedSyncResponseStream(httpx.SyncByteStream): - """Count identity-coded sync response bytes and close on first overrun.""" + """Count exact identity-coded sync response bytes and close on denial.""" def __init__( self, stream: httpx.SyncByteStream, max_response_bytes: int @@ -238,9 +241,12 @@ def __init__( self._max_response_bytes = max_response_bytes def __iter__(self) -> Iterator[bytes]: - """Yield chunks until the next complete chunk exceeds the budget.""" + """Yield exact chunks until the next complete chunk exceeds the budget.""" consumed_bytes = 0 for chunk in self._stream: + if type(chunk) is not bytes: + _close_sync_response_after_policy_denial(self._stream) + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None consumed_bytes += len(chunk) if consumed_bytes > self._max_response_bytes: try: @@ -255,7 +261,7 @@ def close(self) -> None: class _BoundedAsyncResponseStream(httpx.AsyncByteStream): - """Count identity-coded async response bytes and close on first overrun.""" + """Count exact identity-coded async response bytes and close on denial.""" def __init__( self, stream: httpx.AsyncByteStream, max_response_bytes: int @@ -265,9 +271,12 @@ def __init__( self._max_response_bytes = max_response_bytes async def __aiter__(self) -> AsyncIterator[bytes]: - """Yield chunks until the next complete chunk exceeds the budget.""" + """Yield exact chunks until the next complete chunk exceeds the budget.""" consumed_bytes = 0 async for chunk in self._stream: + if type(chunk) is not bytes: + await _close_async_response_after_policy_denial(self._stream) + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None consumed_bytes += len(chunk) if consumed_bytes > self._max_response_bytes: try: From d860c90ec8cc5b6cabf07757ac93b2d272d9c546 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:32:30 +0900 Subject: [PATCH 3/4] test(response): preserve exact byte chunks within budget --- tests/test_response_stream_exact_bytes.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_response_stream_exact_bytes.py b/tests/test_response_stream_exact_bytes.py index ea6bda0..2145f2d 100644 --- a/tests/test_response_stream_exact_bytes.py +++ b/tests/test_response_stream_exact_bytes.py @@ -75,6 +75,15 @@ def test_sync_response_rejects_bytes_subclass_before_length_accounting() -> None assert source.closed is True +def test_sync_response_preserves_exact_bytes_within_budget() -> None: + """Keep ordinary exact byte chunks available when they satisfy the budget.""" + source = _SyncResponseSource(b"0123") + stream = _BoundedSyncResponseStream(source, max_response_bytes=4) + + assert list(stream) == [b"0123"] + assert source.closed is False + + async def test_async_response_rejects_bytes_subclass_before_length_accounting() -> None: """Apply exact response-chunk accounting to the asynchronous boundary.""" source = _AsyncResponseSource(_LyingBytes(b"0123456789")) @@ -85,3 +94,12 @@ async def test_async_response_rejects_bytes_subclass_before_length_accounting() _assert_clean_denial(caught.value) assert source.closed is True + + +async def test_async_response_preserves_exact_bytes_within_budget() -> None: + """Keep ordinary asynchronous byte chunks available within the finite limit.""" + source = _AsyncResponseSource(b"0123") + stream = _BoundedAsyncResponseStream(source, max_response_bytes=4) + + assert [chunk async for chunk in stream] == [b"0123"] + assert source.closed is False From bbb9d52b23319d7a31613cf1eab7eb7a3b275276 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:32:59 +0900 Subject: [PATCH 4/4] docs(changelog): record exact response chunk boundary --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfaecb..02a9a8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). disable the recurring loop. ### Security +- Require exact built-in `bytes` from synchronous and asynchronous dependency + response streams before body-length accounting. Polymorphic chunks can no + longer under-report their payload through subclass-defined `__len__`; rejected + streams close deterministically and callers retain the generic non-leaking + policy denial. - Harden release publication evidence with validated integrating-PR identity, cross-repository required-workflow source checks, and Strix check-run annotations without adding an elevated release credential. @@ -429,4 +434,4 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). gap (CWE-350), with redirects and environment proxies disabled. - `EgressNotAllowedError` (a `ValueError` subclass) and `ValidatedEgressURL`. - 35 tests covering URL rejection, address classification, the `allow_local` - container case, DNS-to-private rejection, and transport pinning. + container case, DNS-to-private rejection, and transport pinning. \ No newline at end of file