Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 34 additions & 29 deletions src/egressweave/sync_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand All @@ -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
Expand All @@ -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
),
)
),
)

Expand All @@ -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
),
)
)
57 changes: 30 additions & 27 deletions src/egressweave/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
*,
Expand All @@ -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
Expand All @@ -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
),
)
),
)

Expand All @@ -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
),
)
)
109 changes: 109 additions & 0 deletions tests/test_public_client_default_headers.py
Original file line number Diff line number Diff line change
@@ -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
Loading