From b4050c0542311e23abd93ff7b633c0ae90ce3a0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:37:21 +0900 Subject: [PATCH 1/8] test(request): reject zero-progress body chunks --- tests/test_request_body_empty_chunks.py | 77 +++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/test_request_body_empty_chunks.py diff --git a/tests/test_request_body_empty_chunks.py b/tests/test_request_body_empty_chunks.py new file mode 100644 index 0000000..67aab04 --- /dev/null +++ b/tests/test_request_body_empty_chunks.py @@ -0,0 +1,77 @@ +"""Regressions for zero-progress outbound request streams.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator + +import httpx +import pytest + +from egressweave import EgressNotAllowedError +from egressweave.request_body_safety import ( + _BoundedAsyncRequestStream, + _BoundedSyncRequestStream, +) + + +class _EmptySyncChunkStream(httpx.SyncByteStream): + """Yield one empty byte chunk and record fail-closed cleanup.""" + + def __init__(self) -> None: + """Initialize the source closure marker.""" + self.closed = False + + def __iter__(self) -> Iterator[bytes]: + """Yield a chunk that consumes no byte budget and makes no progress.""" + yield b"" + + def close(self) -> None: + """Record source closure after policy denial.""" + self.closed = True + + +class _EmptyAsyncChunkStream(httpx.AsyncByteStream): + """Yield one empty async byte chunk and record cleanup.""" + + def __init__(self) -> None: + """Initialize the asynchronous source closure marker.""" + self.closed = False + + async def __aiter__(self) -> AsyncIterator[bytes]: + """Yield a chunk that consumes no byte budget and makes no progress.""" + yield b"" + + async def aclose(self) -> None: + """Record source closure after policy denial.""" + self.closed = True + + +def _assert_clean_denial(error: EgressNotAllowedError) -> None: + """Require the stable non-leaking outbound policy denial.""" + assert str(error) == "egress URL is not allowed" + assert error.__cause__ is None + assert error.__context__ is None + + +def test_sync_request_rejects_empty_chunk_before_dispatch_progress() -> None: + """Prevent a synchronous body from spinning on zero-progress chunks.""" + source = _EmptySyncChunkStream() + stream = _BoundedSyncRequestStream(source, max_request_bytes=1) + + with pytest.raises(EgressNotAllowedError) as caught: + next(iter(stream)) + + _assert_clean_denial(caught.value) + assert source.closed is True + + +async def test_async_request_rejects_empty_chunk_before_dispatch_progress() -> None: + """Prevent an asynchronous body from spinning on zero-progress chunks.""" + source = _EmptyAsyncChunkStream() + stream = _BoundedAsyncRequestStream(source, max_request_bytes=1) + + with pytest.raises(EgressNotAllowedError) as caught: + await anext(stream.__aiter__()) + + _assert_clean_denial(caught.value) + assert source.closed is True From 386bd191dee11a39467f6335ac293d9670e3b0bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:41:18 +0900 Subject: [PATCH 2/8] fix(request): reject zero-progress body chunks --- src/egressweave/request_body_safety.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/egressweave/request_body_safety.py b/src/egressweave/request_body_safety.py index 1df62f5..522c457 100644 --- a/src/egressweave/request_body_safety.py +++ b/src/egressweave/request_body_safety.py @@ -7,12 +7,13 @@ produced by synchronous and asynchronous streams. When a content length is present, actual stream consumption must also equal that declaration exactly. Each bounded request stream is single-consumption so an exhausted or replayable -source cannot be retried under stale framing or a reset allowance. Only exact -built-in ``bytes`` chunks are accepted before length accounting, preventing a -subclass or arbitrary object from executing attacker-controlled conversion or -length behavior at this trust boundary. The stream that would cross any boundary -is closed before the invalid chunk can be sent, while callers continue to -receive EgressWeave's generic non-leaking denial error. +source cannot be retried under stale framing or a reset allowance. Only exact, +non-empty built-in ``bytes`` chunks are accepted before length accounting, +preventing a subclass or zero-progress source from bypassing the finite resource +boundary through conversion behavior or an unbounded no-write loop. A valid +empty body is represented by a stream that yields no chunks. The stream that +would cross any boundary is closed before the invalid chunk can be sent, while +callers continue to receive EgressWeave's generic non-leaking denial error. """ from __future__ import annotations @@ -109,7 +110,7 @@ def __init__( self._iteration_started = False def __iter__(self) -> Iterator[bytes]: - """Yield exact bytes only after cumulative and framing-limit checks.""" + """Yield non-empty exact bytes after progress and framing checks.""" if self._iteration_started: _close_sync_request_after_policy_denial(self._stream) raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None @@ -119,6 +120,9 @@ def __iter__(self) -> Iterator[bytes]: if type(chunk) is not bytes: _close_sync_request_after_policy_denial(self._stream) raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None + if not chunk: + _close_sync_request_after_policy_denial(self._stream) + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None self._consumed_bytes += len(chunk) exceeds_declared_length = ( self._declared_request_bytes is not None @@ -185,7 +189,7 @@ def __init__( self._iteration_started = False async def __aiter__(self) -> AsyncIterator[bytes]: - """Yield exact async bytes after cumulative and framing-limit checks.""" + """Yield non-empty exact async bytes after progress and framing checks.""" if self._iteration_started: await _close_async_request_after_policy_denial(self._stream) raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None @@ -195,6 +199,9 @@ async def __aiter__(self) -> AsyncIterator[bytes]: if type(chunk) is not bytes: await _close_async_request_after_policy_denial(self._stream) raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None + if not chunk: + await _close_async_request_after_policy_denial(self._stream) + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None self._consumed_bytes += len(chunk) exceeds_declared_length = ( self._declared_request_bytes is not None From 6439befba99afa64964b9c59425367d1b17fcc10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:42:59 +0900 Subject: [PATCH 3/8] test(request): preserve bodyless stream semantics --- tests/test_request_body_empty_chunks.py | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/test_request_body_empty_chunks.py b/tests/test_request_body_empty_chunks.py index 67aab04..286539d 100644 --- a/tests/test_request_body_empty_chunks.py +++ b/tests/test_request_body_empty_chunks.py @@ -46,6 +46,39 @@ async def aclose(self) -> None: self.closed = True +class _BodylessSyncStream(httpx.SyncByteStream): + """Represent a valid empty body by yielding no request chunks.""" + + def __init__(self) -> None: + """Initialize the normal caller-controlled closure marker.""" + self.closed = False + + def __iter__(self) -> Iterator[bytes]: + """Finish immediately without producing a zero-progress chunk.""" + return iter(()) + + def close(self) -> None: + """Record normal caller-controlled stream cleanup.""" + self.closed = True + + +class _BodylessAsyncStream(httpx.AsyncByteStream): + """Represent a valid asynchronous empty body with no chunks.""" + + def __init__(self) -> None: + """Initialize the normal asynchronous closure marker.""" + self.closed = False + + async def __aiter__(self) -> AsyncIterator[bytes]: + """Finish immediately without producing a zero-progress chunk.""" + if False: + yield b"unreachable" + + async def aclose(self) -> None: + """Record normal caller-controlled asynchronous cleanup.""" + self.closed = True + + def _assert_clean_denial(error: EgressNotAllowedError) -> None: """Require the stable non-leaking outbound policy denial.""" assert str(error) == "egress URL is not allowed" @@ -75,3 +108,35 @@ async def test_async_request_rejects_empty_chunk_before_dispatch_progress() -> N _assert_clean_denial(caught.value) assert source.closed is True + + +def test_sync_request_allows_a_bodyless_stream_with_no_chunks() -> None: + """Keep an empty request body valid when its stream simply completes.""" + source = _BodylessSyncStream() + stream = _BoundedSyncRequestStream( + source, + max_request_bytes=1, + declared_request_bytes=0, + ) + + assert list(stream) == [] + assert source.closed is False + + stream.close() + assert source.closed is True + + +async def test_async_request_allows_a_bodyless_stream_with_no_chunks() -> None: + """Keep an async empty request valid when its stream simply completes.""" + source = _BodylessAsyncStream() + stream = _BoundedAsyncRequestStream( + source, + max_request_bytes=1, + declared_request_bytes=0, + ) + + assert [chunk async for chunk in stream] == [] + assert source.closed is False + + await stream.aclose() + assert source.closed is True From ed5df8b3938a5a805a46140a8ab9fcce60be54ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:46:32 +0900 Subject: [PATCH 4/8] docs(changelog): record zero-progress request defense --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfaecb..20d7284 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 +- Reject exact empty `bytes` chunks from synchronous and asynchronous outbound + request streams before HTTPCore dispatch. A valid empty body still yields no + chunks, while zero-progress sources now close deterministically and receive + the generic non-leaking denial instead of consuming CPU outside byte and write + timeout budgets. - 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 From f7506edcc87d573fe8014245edc6c0115c81d760 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:52:59 +0900 Subject: [PATCH 5/8] test(request): preserve HTTPX empty bodies --- tests/test_request_body_empty_chunks.py | 85 +++++++------------------ 1 file changed, 23 insertions(+), 62 deletions(-) diff --git a/tests/test_request_body_empty_chunks.py b/tests/test_request_body_empty_chunks.py index 286539d..200e25d 100644 --- a/tests/test_request_body_empty_chunks.py +++ b/tests/test_request_body_empty_chunks.py @@ -1,4 +1,4 @@ -"""Regressions for zero-progress outbound request streams.""" +"""Regressions for bounded zero-progress outbound request streams.""" from __future__ import annotations @@ -14,15 +14,16 @@ ) -class _EmptySyncChunkStream(httpx.SyncByteStream): - """Yield one empty byte chunk and record fail-closed cleanup.""" +class _RepeatedEmptySyncChunkStream(httpx.SyncByteStream): + """Yield repeated empty chunks and record fail-closed cleanup.""" def __init__(self) -> None: """Initialize the source closure marker.""" self.closed = False def __iter__(self) -> Iterator[bytes]: - """Yield a chunk that consumes no byte budget and makes no progress.""" + """Model an unbounded no-write source with two observable iterations.""" + yield b"" yield b"" def close(self) -> None: @@ -30,15 +31,16 @@ def close(self) -> None: self.closed = True -class _EmptyAsyncChunkStream(httpx.AsyncByteStream): - """Yield one empty async byte chunk and record cleanup.""" +class _RepeatedEmptyAsyncChunkStream(httpx.AsyncByteStream): + """Yield repeated empty async chunks and record cleanup.""" def __init__(self) -> None: """Initialize the asynchronous source closure marker.""" self.closed = False async def __aiter__(self) -> AsyncIterator[bytes]: - """Yield a chunk that consumes no byte budget and makes no progress.""" + """Model an async no-write source with two observable iterations.""" + yield b"" yield b"" async def aclose(self) -> None: @@ -46,39 +48,6 @@ async def aclose(self) -> None: self.closed = True -class _BodylessSyncStream(httpx.SyncByteStream): - """Represent a valid empty body by yielding no request chunks.""" - - def __init__(self) -> None: - """Initialize the normal caller-controlled closure marker.""" - self.closed = False - - def __iter__(self) -> Iterator[bytes]: - """Finish immediately without producing a zero-progress chunk.""" - return iter(()) - - def close(self) -> None: - """Record normal caller-controlled stream cleanup.""" - self.closed = True - - -class _BodylessAsyncStream(httpx.AsyncByteStream): - """Represent a valid asynchronous empty body with no chunks.""" - - def __init__(self) -> None: - """Initialize the normal asynchronous closure marker.""" - self.closed = False - - async def __aiter__(self) -> AsyncIterator[bytes]: - """Finish immediately without producing a zero-progress chunk.""" - if False: - yield b"unreachable" - - async def aclose(self) -> None: - """Record normal caller-controlled asynchronous cleanup.""" - self.closed = True - - def _assert_clean_denial(error: EgressNotAllowedError) -> None: """Require the stable non-leaking outbound policy denial.""" assert str(error) == "egress URL is not allowed" @@ -86,33 +55,33 @@ def _assert_clean_denial(error: EgressNotAllowedError) -> None: assert error.__context__ is None -def test_sync_request_rejects_empty_chunk_before_dispatch_progress() -> None: - """Prevent a synchronous body from spinning on zero-progress chunks.""" - source = _EmptySyncChunkStream() +def test_sync_request_rejects_repeated_empty_chunks_before_unbounded_spin() -> None: + """Stop a synchronous source after its second zero-progress chunk.""" + source = _RepeatedEmptySyncChunkStream() stream = _BoundedSyncRequestStream(source, max_request_bytes=1) with pytest.raises(EgressNotAllowedError) as caught: - next(iter(stream)) + list(stream) _assert_clean_denial(caught.value) assert source.closed is True -async def test_async_request_rejects_empty_chunk_before_dispatch_progress() -> None: - """Prevent an asynchronous body from spinning on zero-progress chunks.""" - source = _EmptyAsyncChunkStream() +async def test_async_request_rejects_repeated_empty_chunks_before_unbounded_spin() -> None: + """Stop an asynchronous source after its second zero-progress chunk.""" + source = _RepeatedEmptyAsyncChunkStream() stream = _BoundedAsyncRequestStream(source, max_request_bytes=1) with pytest.raises(EgressNotAllowedError) as caught: - await anext(stream.__aiter__()) + _ = [chunk async for chunk in stream] _assert_clean_denial(caught.value) assert source.closed is True -def test_sync_request_allows_a_bodyless_stream_with_no_chunks() -> None: - """Keep an empty request body valid when its stream simply completes.""" - source = _BodylessSyncStream() +def test_sync_request_preserves_httpx_empty_body_encoding() -> None: + """Consume HTTPX's one canonical empty chunk without dispatching it.""" + source = httpx.ByteStream(b"") stream = _BoundedSyncRequestStream( source, max_request_bytes=1, @@ -120,15 +89,11 @@ def test_sync_request_allows_a_bodyless_stream_with_no_chunks() -> None: ) assert list(stream) == [] - assert source.closed is False - - stream.close() - assert source.closed is True -async def test_async_request_allows_a_bodyless_stream_with_no_chunks() -> None: - """Keep an async empty request valid when its stream simply completes.""" - source = _BodylessAsyncStream() +async def test_async_request_preserves_httpx_empty_body_encoding() -> None: + """Consume HTTPX's async canonical empty chunk without dispatching it.""" + source = httpx.ByteStream(b"") stream = _BoundedAsyncRequestStream( source, max_request_bytes=1, @@ -136,7 +101,3 @@ async def test_async_request_allows_a_bodyless_stream_with_no_chunks() -> None: ) assert [chunk async for chunk in stream] == [] - assert source.closed is False - - await stream.aclose() - assert source.closed is True From 2bca55a6d8feccd11a211bf5295b19b4be71f582 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 01:10:36 +0900 Subject: [PATCH 6/8] fix(request): bound zero-progress chunks compatibly --- src/egressweave/request_body_safety.py | 34 ++++++++++++++++---------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/egressweave/request_body_safety.py b/src/egressweave/request_body_safety.py index 522c457..cb945c1 100644 --- a/src/egressweave/request_body_safety.py +++ b/src/egressweave/request_body_safety.py @@ -7,13 +7,13 @@ produced by synchronous and asynchronous streams. When a content length is present, actual stream consumption must also equal that declaration exactly. Each bounded request stream is single-consumption so an exhausted or replayable -source cannot be retried under stale framing or a reset allowance. Only exact, -non-empty built-in ``bytes`` chunks are accepted before length accounting, -preventing a subclass or zero-progress source from bypassing the finite resource -boundary through conversion behavior or an unbounded no-write loop. A valid -empty body is represented by a stream that yields no chunks. The stream that -would cross any boundary is closed before the invalid chunk can be sent, while -callers continue to receive EgressWeave's generic non-leaking denial error. +source cannot be retried under stale framing or a reset allowance. Only exact +built-in ``bytes`` chunks are accepted before length accounting. At most one +empty chunk is consumed without dispatch so HTTPX's canonical empty-body stream +remains compatible while a repeated zero-progress source fails closed instead +of spinning outside the byte and write-timeout budgets. The stream that would +cross any boundary is closed before the invalid chunk can be sent, while callers +continue to receive EgressWeave's generic non-leaking denial error. """ from __future__ import annotations @@ -110,19 +110,23 @@ def __init__( self._iteration_started = False def __iter__(self) -> Iterator[bytes]: - """Yield non-empty exact bytes after progress and framing checks.""" + """Yield exact bytes after bounded-progress and framing checks.""" if self._iteration_started: _close_sync_request_after_policy_denial(self._stream) raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None self._iteration_started = True + empty_chunk_seen = False for chunk in self._stream: if type(chunk) is not bytes: _close_sync_request_after_policy_denial(self._stream) raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None if not chunk: - _close_sync_request_after_policy_denial(self._stream) - raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None + if empty_chunk_seen: + _close_sync_request_after_policy_denial(self._stream) + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None + empty_chunk_seen = True + continue self._consumed_bytes += len(chunk) exceeds_declared_length = ( self._declared_request_bytes is not None @@ -189,19 +193,23 @@ def __init__( self._iteration_started = False async def __aiter__(self) -> AsyncIterator[bytes]: - """Yield non-empty exact async bytes after progress and framing checks.""" + """Yield exact async bytes after bounded-progress and framing checks.""" if self._iteration_started: await _close_async_request_after_policy_denial(self._stream) raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None self._iteration_started = True + empty_chunk_seen = False async for chunk in self._stream: if type(chunk) is not bytes: await _close_async_request_after_policy_denial(self._stream) raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None if not chunk: - await _close_async_request_after_policy_denial(self._stream) - raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None + if empty_chunk_seen: + await _close_async_request_after_policy_denial(self._stream) + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None + empty_chunk_seen = True + continue self._consumed_bytes += len(chunk) exceeds_declared_length = ( self._declared_request_bytes is not None From 8cb7f89aa4a1066c183751bb75070c1810fa4522 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 04:12:04 +0900 Subject: [PATCH 7/8] test(request): expose zero-progress changelog mismatch --- tests/test_request_body_empty_chunks.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_request_body_empty_chunks.py b/tests/test_request_body_empty_chunks.py index 200e25d..b75162e 100644 --- a/tests/test_request_body_empty_chunks.py +++ b/tests/test_request_body_empty_chunks.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Iterator +from pathlib import Path import httpx import pytest @@ -101,3 +102,11 @@ async def test_async_request_preserves_httpx_empty_body_encoding() -> None: ) assert [chunk async for chunk in stream] == [] + + +def test_changelog_describes_repeated_zero_progress_boundary() -> None: + """Keep release history aligned with the one-empty-chunk compatibility rule.""" + changelog = Path("CHANGELOG.md").read_text(encoding="utf-8") + + assert "Reject repeated exact empty `bytes` chunks" in changelog + assert "Reject exact empty `bytes` chunks" not in changelog From 50b9951255485c3fd557d2bfd14fb6f9acbf490c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 04:16:01 +0900 Subject: [PATCH 8/8] docs(request): align zero-progress security history --- CHANGELOG.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d7284..f963825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,11 +88,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). disable the recurring loop. ### Security -- Reject exact empty `bytes` chunks from synchronous and asynchronous outbound - request streams before HTTPCore dispatch. A valid empty body still yields no - chunks, while zero-progress sources now close deterministically and receive - the generic non-leaking denial instead of consuming CPU outside byte and write - timeout budgets. +- Reject repeated exact empty `bytes` chunks from synchronous and asynchronous + outbound request streams after permitting one compatibility empty chunk without + dispatch. A valid empty body can therefore retain HTTPX's canonical one-empty- + chunk encoding, while a second zero-progress chunk closes the source + deterministically and receives the generic non-leaking denial instead of + consuming CPU outside byte and write timeout budgets. - 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.