From 62fc45ed3c41ffdc15e197d8e7c912375b8c5bbe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:19:14 +0900 Subject: [PATCH 1/3] test: reproduce public client default header self-denial --- tests/test_public_client_default_headers.py | 109 ++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/test_public_client_default_headers.py diff --git a/tests/test_public_client_default_headers.py b/tests/test_public_client_default_headers.py new file mode 100644 index 0000000..c7285e6 --- /dev/null +++ b/tests/test_public_client_default_headers.py @@ -0,0 +1,109 @@ +"""Public-client regression contracts for safe framework request defaults.""" + +from __future__ import annotations + +import httpcore + +from egressweave import ( + EgressPolicy, + build_pinned_https_async_client, + build_pinned_https_client, + validate_egress_url_details, +) +from egressweave import validation as v + +POLICY = EgressPolicy.from_hosts("api.openai.com") + + +def _validated_result(monkeypatch): + """Return deterministic public validation state without external DNS.""" + + def fake_getaddrinfo(host, port, type=None): + return [(2, 1, 6, "", ("93.184.216.34", port))] + + monkeypatch.setattr(v.socket, "getaddrinfo", fake_getaddrinfo) + validated = validate_egress_url_details( + "https://api.openai.com", policy=POLICY + ) + assert validated is not None + return validated + + +class _SyncRecordingPool: + """Record the exact synchronous request reaching the pinned pool.""" + + def __init__(self) -> None: + self.requests = [] + self.closed = False + + def handle_request(self, request): + self.requests.append(request) + return httpcore.Response(204, headers=[], content=b"") + + def close(self) -> None: + self.closed = True + + +class _AsyncRecordingPool: + """Record the exact asynchronous request reaching the pinned pool.""" + + def __init__(self) -> None: + self.requests = [] + self.closed = False + + async def handle_async_request(self, request): + self.requests.append(request) + + async def empty_content(): + if False: # pragma: no cover - async iterator shape only + yield b"" + + return httpcore.Response(204, headers=[], content=empty_content()) + + async def aclose(self) -> None: + self.closed = True + + +def _assert_no_hop_by_hop_defaults(request) -> None: + """Require the final pinned request to contain no ambient connection controls.""" + names = {name.lower() for name, _ in request.headers} + assert b"connection" not in names + assert b"keep-alive" not in names + assert b"proxy-authenticate" not in names + assert b"proxy-authorization" not in names + assert b"proxy-connection" not in names + assert b"upgrade" not in names + + +def test_sync_public_client_dispatches_ordinary_get_without_ambient_hop_by_hop( + monkeypatch, +) -> None: + """A caller should not need to delete HTTPX defaults before a safe GET.""" + validated = _validated_result(monkeypatch) + pool = _SyncRecordingPool() + + with build_pinned_https_client(validated, policy=POLICY) as client: + client._transport._pool = pool + response = client.get("https://api.openai.com/v1/models") + + assert response.status_code == 204 + assert len(pool.requests) == 1 + _assert_no_hop_by_hop_defaults(pool.requests[0]) + assert pool.closed is True + + +async def test_async_public_client_dispatches_ordinary_get_without_ambient_hop_by_hop( + monkeypatch, +) -> None: + """The async public builder must normalize the same framework defaults.""" + validated = _validated_result(monkeypatch) + pool = _AsyncRecordingPool() + + async with build_pinned_https_async_client(validated, policy=POLICY) as client: + client._transport._pool = pool + response = await client.get("https://api.openai.com/v1/models") + + assert response.status_code == 204 + assert len(pool.requests) == 1 + _assert_no_hop_by_hop_defaults(pool.requests[0]) + assert pool.closed is True From 1ce0421fb94b7bc1ec74c6a1a480bd78d0de4f96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:24:37 +0900 Subject: [PATCH 2/3] fix: remove ambient HTTPX connection header --- src/egressweave/sync_transport.py | 63 +++++++++++++++++-------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/src/egressweave/sync_transport.py b/src/egressweave/sync_transport.py index 975b28a..fef8a1d 100644 --- a/src/egressweave/sync_transport.py +++ b/src/egressweave/sync_transport.py @@ -293,6 +293,17 @@ def close(self) -> None: self._pool.close() +def _build_sync_httpx_client(transport: httpx.BaseTransport) -> httpx.Client: + """Build a client without HTTPX's ambient hop-by-hop connection header.""" + client = httpx.Client( + follow_redirects=False, + trust_env=False, + transport=transport, + ) + client.headers.pop("connection", None) + return client + + def build_egress_sync_client( base_url: str | None, *, @@ -304,9 +315,11 @@ def build_egress_sync_client( Returns ``(normalized_url, client)``. When ``base_url`` is empty or absent, the normalized URL is ``None`` and the returned client rejects every request before network I/O. A non-empty URL that violates the policy raises - :class:`~egressweave.validation.EgressNotAllowedError`. Exact outbound - targets are limited by ``policy.max_request_target_bytes``. Final outbound - fields are limited by ``policy.max_request_header_fields`` and + :class:`~egressweave.validation.EgressNotAllowedError`. The builder removes + HTTPX's ambient ``Connection`` header so ordinary caller requests reach the + transport's strict hop-by-hop-header policy without weakening that policy. + Exact outbound targets are limited by ``policy.max_request_target_bytes``. + Final outbound fields are limited by ``policy.max_request_header_fields`` and ``policy.max_request_header_bytes``. Request bodies are limited to ``policy.max_request_bytes`` and must match a supplied ``Content-Length`` exactly. Request-phase timeout metadata is capped by @@ -318,22 +331,13 @@ def build_egress_sync_client( """ validated = validate_egress_url_details(base_url, policy=policy) if validated is None: - return ( - None, - httpx.Client( - follow_redirects=False, - trust_env=False, - transport=_DenyAllSyncTransport(), - ), - ) + return None, _build_sync_httpx_client(_DenyAllSyncTransport()) return ( validated.normalized_url, - httpx.Client( - follow_redirects=False, - trust_env=False, - transport=_PinnedEgressTransport( + _build_sync_httpx_client( + _PinnedEgressTransport( validated, policy, tls_configuration=tls_configuration - ), + ) ), ) @@ -346,19 +350,20 @@ def build_pinned_https_client( ) -> httpx.Client: """Build a synchronous DNS-pinned HTTPX client from validated URL state. - The supplied result is revalidated without another DNS lookup. Every - connection is pinned to its addresses, any forged result or authority change - is rejected before network I/O, every exact outbound target and request - header section is bounded after trusted rewriting, every request body is - constrained by ``policy.max_request_bytes`` and exact declared framing, - every request phase is capped by ``policy.request_timeout_policy``, response - metadata is bounded by the finite header policy, and every identity-coded - response body is constrained by ``policy.max_response_bytes``. + The supplied result is revalidated without another DNS lookup. HTTPX's + ambient ``Connection`` header is removed at construction while any caller- + supplied hop-by-hop field remains subject to the transport's fail-closed + request-header policy. Every connection is pinned to its addresses, any + forged result or authority change is rejected before network I/O, every + exact outbound target and request header section is bounded after trusted + rewriting, every request body is constrained by ``policy.max_request_bytes`` + and exact declared framing, every request phase is capped by + ``policy.request_timeout_policy``, response metadata is bounded by the finite + header policy, and every identity-coded response body is constrained by + ``policy.max_response_bytes``. """ - return httpx.Client( - follow_redirects=False, - trust_env=False, - transport=_PinnedEgressTransport( + return _build_sync_httpx_client( + _PinnedEgressTransport( validated, policy, tls_configuration=tls_configuration - ), + ) ) From 2737241bc3c64a13c4b587c6d69fdc621d2b628a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 17:31:51 +0900 Subject: [PATCH 3/3] fix: normalize async HTTPX connection default --- src/egressweave/transport.py | 57 +++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/egressweave/transport.py b/src/egressweave/transport.py index f9d91cf..49f67ae 100644 --- a/src/egressweave/transport.py +++ b/src/egressweave/transport.py @@ -211,11 +211,7 @@ def start_next_attempt() -> bool: if remaining_timeout == 0.0: more_addresses = False return False - tasks.add( - asyncio.create_task( - connect_after_stagger(address) - ) - ) + tasks.add(asyncio.create_task(connect_after_stagger(address))) next_attempt_at = loop.time() + _CONNECTION_ATTEMPT_DELAY_SECONDS return True @@ -450,6 +446,17 @@ async def aclose(self) -> None: await self._pool.aclose() +def _build_async_httpx_client(transport: httpx.AsyncBaseTransport) -> httpx.AsyncClient: + """Build a client without HTTPX's ambient hop-by-hop connection header.""" + client = httpx.AsyncClient( + follow_redirects=False, + trust_env=False, + transport=transport, + ) + client.headers.pop("connection", None) + return client + + async def build_egress_http_client( base_url: str | None, *, @@ -458,9 +465,11 @@ async def build_egress_http_client( ) -> tuple[str | None, httpx.AsyncClient]: """Build a DNS-pinned, fail-closed client for ``base_url``. - Empty or absent URLs return a deny-all client. Exact outbound targets are - limited by ``policy.max_request_target_bytes``. Final outbound fields are - limited by ``policy.max_request_header_fields`` and + Empty or absent URLs return a deny-all client. HTTPX's ambient ``Connection`` + header is removed at construction while caller-supplied hop-by-hop fields + remain subject to the transport's strict request-header policy. Exact outbound + targets are limited by ``policy.max_request_target_bytes``. Final outbound + fields are limited by ``policy.max_request_header_fields`` and ``policy.max_request_header_bytes``. Request bodies are limited to ``policy.max_request_bytes`` and must match a supplied ``Content-Length`` exactly. Request-phase timeout metadata is capped by @@ -472,22 +481,13 @@ async def build_egress_http_client( """ validated = await validate_egress_url_details_async(base_url, policy=policy) if validated is None: - return ( - None, - httpx.AsyncClient( - follow_redirects=False, - trust_env=False, - transport=_DenyAllAsyncTransport(), - ), - ) + return None, _build_async_httpx_client(_DenyAllAsyncTransport()) return ( validated.normalized_url, - httpx.AsyncClient( - follow_redirects=False, - trust_env=False, - transport=_PinnedEgressAsyncTransport( + _build_async_httpx_client( + _PinnedEgressAsyncTransport( validated, policy, tls_configuration=tls_configuration - ), + ) ), ) @@ -498,11 +498,14 @@ def build_pinned_https_async_client( policy: EgressPolicy, tls_configuration: TLSConfiguration | None = None, ) -> httpx.AsyncClient: - """Build an async client with bounded request, timeout, and response policy.""" - return httpx.AsyncClient( - follow_redirects=False, - trust_env=False, - transport=_PinnedEgressAsyncTransport( + """Build an async client with bounded request, timeout, and response policy. + + HTTPX's ambient ``Connection`` header is removed at construction while any + caller-supplied hop-by-hop field remains subject to the fail-closed transport + policy. + """ + return _build_async_httpx_client( + _PinnedEgressAsyncTransport( validated, policy, tls_configuration=tls_configuration - ), + ) )