From b6c966451967f094db82ce6f7797c71576aaa107 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:23:36 +0900 Subject: [PATCH 01/26] test: reproduce same-authority DNS amplification on integrated main --- ...ns_resolution_singleflight_current_main.py | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/test_dns_resolution_singleflight_current_main.py diff --git a/tests/test_dns_resolution_singleflight_current_main.py b/tests/test_dns_resolution_singleflight_current_main.py new file mode 100644 index 00000000..2800e002 --- /dev/null +++ b/tests/test_dns_resolution_singleflight_current_main.py @@ -0,0 +1,114 @@ +"""Current-main RED contract for bounded same-authority DNS resolver work.""" + +from __future__ import annotations + +import queue +import threading +import time + +from egressweave import EGRESS_NOT_ALLOWED, EgressNotAllowedError, EgressPolicy +from egressweave import validate_egress_url_details +from egressweave import validation + +AUTHORITY = ("api.example.com", 443) +PUBLIC_ADDRESS = "93.184.216.34" +CALLER_COUNT = 4 +DNS_TIMEOUT_SECONDS = 0.10 + + +class _CountingResolutionSlots: + """Allow resolver work while recording acquired and released worker slots.""" + + def __init__(self) -> None: + self.acquire_count = 0 + self.release_count = 0 + self._condition = threading.Condition() + + def acquire(self, *, timeout: float) -> bool: + """Record one successful resolver-slot acquisition.""" + assert timeout > 0 + with self._condition: + self.acquire_count += 1 + self._condition.notify_all() + return True + + def release(self) -> None: + """Record one resolver-slot release.""" + with self._condition: + self.release_count += 1 + self._condition.notify_all() + + def wait_until_balanced(self, timeout: float) -> bool: + """Wait until every acquired resolver slot has been released.""" + deadline = time.monotonic() + timeout + with self._condition: + while self.release_count < self.acquire_count: + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + self._condition.wait(remaining) + return True + + +def test_same_authority_timeouts_share_one_live_resolver(monkeypatch) -> None: + """Overlapping callers must not multiply one slow authority's resolver work.""" + slots = _CountingResolutionSlots() + release_resolver = threading.Event() + call_lock = threading.Lock() + caller_barrier = threading.Barrier(CALLER_COUNT + 1) + outcomes: queue.Queue[str] = queue.Queue() + call_count = 0 + + def slow_getaddrinfo(hostname, port, *, type): + nonlocal call_count + assert (hostname, port) == AUTHORITY + assert type == validation.socket.SOCK_STREAM + with call_lock: + call_count += 1 + if not release_resolver.wait(timeout=5.0): + raise RuntimeError("test resolver was not released") + return [(2, 1, 6, "", (PUBLIC_ADDRESS, port))] + + monkeypatch.setattr(validation.socket, "getaddrinfo", slow_getaddrinfo) + monkeypatch.setattr(validation, "_DNS_RESOLUTION_SLOTS", slots) + policy = EgressPolicy.from_hosts( + AUTHORITY[0], + dns_timeout_seconds=DNS_TIMEOUT_SECONDS, + ) + + def validate_from_caller() -> None: + try: + caller_barrier.wait(timeout=5.0) + validate_egress_url_details( + f"https://{AUTHORITY[0]}", + policy=policy, + ) + except EgressNotAllowedError as exc: + outcomes.put(str(exc)) + except Exception as exc: # noqa: BLE001 - preserve thread diagnostics + outcomes.put(f"unexpected {type(exc).__name__}: {exc}") + else: + outcomes.put("unexpected success") + + callers = [ + threading.Thread(target=validate_from_caller, name=f"dns-caller-{index}") + for index in range(CALLER_COUNT) + ] + for caller in callers: + caller.start() + + try: + caller_barrier.wait(timeout=5.0) + for caller in callers: + caller.join(timeout=DNS_TIMEOUT_SECONDS + 2.0) + + assert not any(caller.is_alive() for caller in callers) + assert sorted(outcomes.get_nowait() for _ in range(CALLER_COUNT)) == [ + EGRESS_NOT_ALLOWED + ] * CALLER_COUNT + with call_lock: + assert call_count == 1 + assert slots.acquire_count == 1 + finally: + release_resolver.set() + assert slots.wait_until_balanced(timeout=3.0) From 3bc999dde8c9a400f7d7a2070268a53073d98614 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:30:24 +0900 Subject: [PATCH 02/26] test: fix DNS single-flight RED import ordering --- tests/test_dns_resolution_singleflight_current_main.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_dns_resolution_singleflight_current_main.py b/tests/test_dns_resolution_singleflight_current_main.py index 2800e002..230a4690 100644 --- a/tests/test_dns_resolution_singleflight_current_main.py +++ b/tests/test_dns_resolution_singleflight_current_main.py @@ -6,9 +6,13 @@ import threading import time -from egressweave import EGRESS_NOT_ALLOWED, EgressNotAllowedError, EgressPolicy -from egressweave import validate_egress_url_details -from egressweave import validation +from egressweave import ( + EGRESS_NOT_ALLOWED, + EgressNotAllowedError, + EgressPolicy, + validate_egress_url_details, + validation, +) AUTHORITY = ("api.example.com", 443) PUBLIC_ADDRESS = "93.184.216.34" From e1b6add4d43f66c76bf8d70462ad90c191418965 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:34:42 +0900 Subject: [PATCH 03/26] reliability: deduplicate live DNS resolution workers --- src/egressweave/validation.py | 147 +++++++++++++++++++++++----------- 1 file changed, 101 insertions(+), 46 deletions(-) diff --git a/src/egressweave/validation.py b/src/egressweave/validation.py index 9542ab9e..e2f29fcb 100644 --- a/src/egressweave/validation.py +++ b/src/egressweave/validation.py @@ -18,7 +18,6 @@ import hashlib import hmac import ipaddress -import queue import secrets import socket import threading @@ -34,6 +33,19 @@ _MAX_CONCURRENT_DNS_RESOLUTIONS = 32 _DNS_RESOLUTION_SLOTS = threading.BoundedSemaphore(_MAX_CONCURRENT_DNS_RESOLUTIONS) + +@dataclass +class _DNSResolutionFlight: + """Share one live resolver result among callers of the same authority.""" + + completed: threading.Event = field(default_factory=threading.Event) + raw_addresses: tuple[str, ...] | None = None + error: Exception | None = None + + +_DNS_RESOLUTION_FLIGHTS_LOCK = threading.Lock() +_DNS_RESOLUTION_FLIGHTS: dict[tuple[str, int], _DNSResolutionFlight] = {} + _LOCAL_DEV_HOSTNAMES = frozenset({"localhost", "localhost.localdomain"}) _PRIVATE_LOCAL_NETWORKS = ( ipaddress.ip_network("10.0.0.0/8"), @@ -222,10 +234,8 @@ def _validate_bounded_unique_addresses( return tuple(validated_addresses) -def _resolve_all_global_addresses_blocking( - hostname: str, port: int, policy: EgressPolicy -) -> tuple[str, ...]: - """Resolve and validate a finite unique address set on the worker thread.""" +def _resolve_raw_addresses_blocking(hostname: str, port: int) -> tuple[str, ...]: + """Resolve raw address strings without applying any caller-specific policy.""" try: address_infos = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM) except (OSError, UnicodeError) as exc: @@ -233,71 +243,116 @@ def _resolve_all_global_addresses_blocking( if not address_infos: raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) + return tuple(str(address_info[4][0]) for address_info in address_infos) + + +def _resolve_all_global_addresses_blocking( + hostname: str, port: int, policy: EgressPolicy +) -> tuple[str, ...]: + """Resolve and validate a finite unique address set on the worker thread.""" return _validate_bounded_unique_addresses( - (str(address_info[4][0]) for address_info in address_infos), + _resolve_raw_addresses_blocking(hostname, port), policy, hostname=hostname, ) +def _join_dns_resolution_flight( + hostname: str, + port: int, +) -> tuple[tuple[str, int], _DNSResolutionFlight, bool]: + """Return the live authority flight, creating it for exactly one owner.""" + key = (hostname, port) + with _DNS_RESOLUTION_FLIGHTS_LOCK: + flight = _DNS_RESOLUTION_FLIGHTS.get(key) + if flight is not None: + return key, flight, False + flight = _DNSResolutionFlight() + _DNS_RESOLUTION_FLIGHTS[key] = flight + return key, flight, True + + +def _complete_dns_resolution_flight( + key: tuple[str, int], + flight: _DNSResolutionFlight, +) -> None: + """Remove one completed live flight before waking its current waiters.""" + with _DNS_RESOLUTION_FLIGHTS_LOCK: + _DNS_RESOLUTION_FLIGHTS.pop(key, None) + flight.completed.set() + + def _resolve_all_global_addresses( hostname: str, port: int, policy: EgressPolicy ) -> tuple[str, ...]: """Resolve addresses within a bounded, fail-closed synchronous deadline.""" deadline = time.monotonic() + policy.dns_timeout_seconds - if not _DNS_RESOLUTION_SLOTS.acquire(timeout=policy.dns_timeout_seconds): - raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) + key, flight, owns_flight = _join_dns_resolution_flight(hostname, port) - remaining_timeout = deadline - time.monotonic() - if remaining_timeout <= 0: - _DNS_RESOLUTION_SLOTS.release() - raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) + if owns_flight: + if not _DNS_RESOLUTION_SLOTS.acquire(timeout=policy.dns_timeout_seconds): + flight.error = EgressNotAllowedError(EGRESS_NOT_ALLOWED) + _complete_dns_resolution_flight(key, flight) + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) - resolution_result: queue.Queue[ - tuple[tuple[str, ...] | None, Exception | None] - ] = queue.Queue(maxsize=1) + remaining_timeout = deadline - time.monotonic() + if remaining_timeout <= 0: + _DNS_RESOLUTION_SLOTS.release() + flight.error = EgressNotAllowedError(EGRESS_NOT_ALLOWED) + _complete_dns_resolution_flight(key, flight) + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) - def resolve_on_worker() -> None: + def resolve_on_worker() -> None: + try: + flight.raw_addresses = _resolve_raw_addresses_blocking(hostname, port) + except Exception as exc: # noqa: BLE001 + flight.error = exc + finally: + _DNS_RESOLUTION_SLOTS.release() + _complete_dns_resolution_flight(key, flight) + + worker = threading.Thread( + target=resolve_on_worker, + name="egressweave-dns-resolver", + daemon=True, + ) + worker_start_failed = False try: - addresses = _resolve_all_global_addresses_blocking(hostname, port, policy) - resolution_result.put((addresses, None)) - except Exception as exc: # noqa: BLE001 - resolution_result.put((None, exc)) - finally: + worker.start() + except RuntimeError as exc: _DNS_RESOLUTION_SLOTS.release() + flight.error = exc + _complete_dns_resolution_flight(key, flight) + worker_start_failed = True + if worker_start_failed: + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None - worker = threading.Thread( - target=resolve_on_worker, - name="egressweave-dns-resolver", - daemon=True, - ) - try: - worker.start() - except RuntimeError as exc: - _DNS_RESOLUTION_SLOTS.release() - raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from exc - - try: - addresses, error = resolution_result.get( - timeout=max(0.0, deadline - time.monotonic()) - ) - except queue.Empty as exc: - raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from exc + remaining_timeout = deadline - time.monotonic() + if remaining_timeout <= 0 or not flight.completed.wait(timeout=remaining_timeout): + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None - if error is not None: - if isinstance(error, EgressNotAllowedError): - raise error - raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from error - if addresses is None: + if flight.error is not None: + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None + if flight.raw_addresses is None: raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) - return addresses + return _validate_bounded_unique_addresses( + flight.raw_addresses, + policy, + hostname=hostname, + ) async def _resolve_all_global_addresses_async( hostname: str, port: int, policy: EgressPolicy ) -> tuple[str, ...]: - """Run the same bounded resolver without blocking the event loop.""" - return await asyncio.to_thread(_resolve_all_global_addresses, hostname, port, policy) + """Run the bounded resolver under one caller-owned asynchronous deadline.""" + try: + return await asyncio.wait_for( + asyncio.to_thread(_resolve_all_global_addresses, hostname, port, policy), + timeout=policy.dns_timeout_seconds, + ) + except asyncio.TimeoutError: + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None def _parse_and_validate_candidate_url( From d1274403b3f6905a5883b107ebce97315f8b6cb4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:35:35 +0900 Subject: [PATCH 04/26] test: cover DNS single-flight semantics --- tests/test_dns_resolution_singleflight.py | 322 ++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 tests/test_dns_resolution_singleflight.py diff --git a/tests/test_dns_resolution_singleflight.py b/tests/test_dns_resolution_singleflight.py new file mode 100644 index 00000000..8f3cd199 --- /dev/null +++ b/tests/test_dns_resolution_singleflight.py @@ -0,0 +1,322 @@ +"""Regression contracts for bounded in-flight DNS resolution work.""" + +from __future__ import annotations + +import asyncio +import queue +import threading +import time + +import pytest + +from egressweave import ( + EGRESS_NOT_ALLOWED, + EgressNotAllowedError, + EgressPolicy, + validate_egress_url_details, + validate_egress_url_details_async, + validation, +) + +PUBLIC_ADDRESS = "93.184.216.34" +SECOND_PUBLIC_ADDRESS = "93.184.216.35" +CALLER_COUNT = 4 +DNS_TIMEOUT_SECONDS = 0.75 +AUTHORITY_KEY = ("api.example.com", 443) + + +class _CountingResolutionSlots: + """Allow resolver work while recording acquired and released worker slots.""" + + def __init__(self) -> None: + self.acquire_count = 0 + self.release_count = 0 + self._condition = threading.Condition() + + def acquire(self, *, timeout: float) -> bool: + """Record one successful acquisition without imposing a test-only ceiling.""" + assert timeout > 0 + with self._condition: + self.acquire_count += 1 + self._condition.notify_all() + return True + + def release(self) -> None: + """Record one worker-slot release.""" + with self._condition: + self.release_count += 1 + self._condition.notify_all() + + def wait_until_balanced(self, timeout: float) -> bool: + """Wait until every slot acquired by this test has been released.""" + deadline = time.monotonic() + timeout + with self._condition: + while self.release_count < self.acquire_count: + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + self._condition.wait(remaining) + return True + + +def _install_blocking_resolver(monkeypatch): + """Install one deterministic resolver that stays live past caller deadlines.""" + release_resolver = threading.Event() + call_lock = threading.Lock() + call_count = 0 + + def slow_getaddrinfo(hostname, port, *, type): + nonlocal call_count + assert hostname == AUTHORITY_KEY[0] + assert port == AUTHORITY_KEY[1] + assert type == validation.socket.SOCK_STREAM + with call_lock: + call_count += 1 + if not release_resolver.wait(timeout=5.0): + raise RuntimeError("test resolver was not released") + return [(2, 1, 6, "", (PUBLIC_ADDRESS, port))] + + monkeypatch.setattr(validation.socket, "getaddrinfo", slow_getaddrinfo) + + def observed_calls() -> int: + with call_lock: + return call_count + + return release_resolver, observed_calls + + +def _active_authority_flight(): + """Return the currently registered test-authority flight, if one is live.""" + with validation._DNS_RESOLUTION_FLIGHTS_LOCK: + return validation._DNS_RESOLUTION_FLIGHTS.get(AUTHORITY_KEY) + + +def _release_and_wait_for_flight( + release_resolver: threading.Event, + slots: _CountingResolutionSlots, +) -> None: + """Release a controlled worker and wait for slot plus registry cleanup.""" + flight = _active_authority_flight() + release_resolver.set() + if flight is not None: + assert flight.completed.wait(timeout=3.0) + assert slots.wait_until_balanced(timeout=3.0) + assert _active_authority_flight() is None + + +def _policy(*, max_resolved_addresses: int = 16) -> EgressPolicy: + """Return the short-deadline policy used by the concurrency regressions.""" + return EgressPolicy.from_hosts( + AUTHORITY_KEY[0], + dns_timeout_seconds=DNS_TIMEOUT_SECONDS, + max_resolved_addresses=max_resolved_addresses, + ) + + +def test_sync_same_authority_timeouts_share_one_live_resolver(monkeypatch) -> None: + """Repeated sync timeouts for one authority must share one live DNS worker.""" + slots = _CountingResolutionSlots() + release_resolver, observed_calls = _install_blocking_resolver(monkeypatch) + monkeypatch.setattr(validation, "_DNS_RESOLUTION_SLOTS", slots) + caller_barrier = threading.Barrier(CALLER_COUNT + 1) + outcomes: queue.Queue[str] = queue.Queue() + + def validate_from_caller() -> None: + try: + caller_barrier.wait(timeout=5.0) + validate_egress_url_details( + "https://api.example.com", + policy=_policy(), + ) + except EgressNotAllowedError as exc: + outcomes.put(str(exc)) + except Exception as exc: # noqa: BLE001 - preserve thread diagnostics + outcomes.put(f"unexpected {type(exc).__name__}: {exc}") + else: + outcomes.put("unexpected success") + + callers = [ + threading.Thread(target=validate_from_caller, name=f"dns-caller-{index}") + for index in range(CALLER_COUNT) + ] + for caller in callers: + caller.start() + + try: + caller_barrier.wait(timeout=5.0) + for caller in callers: + caller.join(timeout=DNS_TIMEOUT_SECONDS + 2.0) + + assert not any(caller.is_alive() for caller in callers) + assert sorted(outcomes.get_nowait() for _ in range(CALLER_COUNT)) == [ + EGRESS_NOT_ALLOWED + ] * CALLER_COUNT + assert observed_calls() == 1 + assert slots.acquire_count == 1 + finally: + _release_and_wait_for_flight(release_resolver, slots) + + +async def test_async_same_authority_timeouts_share_one_live_resolver(monkeypatch) -> None: + """Repeated async timeouts for one authority must share the same live worker.""" + slots = _CountingResolutionSlots() + release_resolver, observed_calls = _install_blocking_resolver(monkeypatch) + monkeypatch.setattr(validation, "_DNS_RESOLUTION_SLOTS", slots) + start_callers = asyncio.Event() + + async def validate_from_caller() -> None: + await start_callers.wait() + with pytest.raises( + EgressNotAllowedError, + match=f"^{EGRESS_NOT_ALLOWED}$", + ): + await validate_egress_url_details_async( + "https://api.example.com", + policy=_policy(), + ) + + callers = [asyncio.create_task(validate_from_caller()) for _ in range(CALLER_COUNT)] + await asyncio.sleep(0) + + try: + start_callers.set() + await asyncio.wait_for( + asyncio.gather(*callers), + timeout=DNS_TIMEOUT_SECONDS + 3.0, + ) + assert observed_calls() == 1 + assert slots.acquire_count == 1 + finally: + _release_and_wait_for_flight(release_resolver, slots) + + +def test_empty_shared_resolver_result_remains_generic(monkeypatch) -> None: + """Normalize one shared worker's empty DNS result to the public denial.""" + monkeypatch.setattr(validation.socket, "getaddrinfo", lambda *args, **kwargs: []) + + with pytest.raises( + EgressNotAllowedError, + match=f"^{EGRESS_NOT_ALLOWED}$", + ): + validate_egress_url_details("https://api.example.com", policy=_policy()) + + +def test_completed_flight_without_outcome_fails_closed() -> None: + """Reject an internally incomplete completed flight instead of trusting it.""" + flight = validation._DNSResolutionFlight() + flight.completed.set() + with validation._DNS_RESOLUTION_FLIGHTS_LOCK: + validation._DNS_RESOLUTION_FLIGHTS[AUTHORITY_KEY] = flight + + try: + with pytest.raises( + EgressNotAllowedError, + match=f"^{EGRESS_NOT_ALLOWED}$", + ): + validate_egress_url_details("https://api.example.com", policy=_policy()) + finally: + with validation._DNS_RESOLUTION_FLIGHTS_LOCK: + validation._DNS_RESOLUTION_FLIGHTS.pop(AUTHORITY_KEY, None) + + +def test_completed_resolution_is_not_cached(monkeypatch) -> None: + """Perform a fresh DNS lookup after each completed same-authority flight.""" + call_count = 0 + + def counting_getaddrinfo(hostname, port, *, type): + nonlocal call_count + call_count += 1 + return [(2, 1, 6, "", (PUBLIC_ADDRESS, port))] + + monkeypatch.setattr(validation.socket, "getaddrinfo", counting_getaddrinfo) + + first = validate_egress_url_details("https://api.example.com", policy=_policy()) + second = validate_egress_url_details("https://api.example.com", policy=_policy()) + + assert first is not None + assert second is not None + assert first.addresses == (PUBLIC_ADDRESS,) + assert second.addresses == (PUBLIC_ADDRESS,) + assert call_count == 2 + + +def test_shared_raw_result_is_validated_under_each_callers_policy(monkeypatch) -> None: + """Keep policy-specific address cardinality outside the shared DNS worker.""" + release_resolver = threading.Event() + resolver_started = threading.Event() + joiner_arrived = threading.Event() + call_count = 0 + original_join = validation._join_dns_resolution_flight + + def observing_join(hostname: str, port: int): + result = original_join(hostname, port) + if not result[2]: + joiner_arrived.set() + return result + + def two_address_getaddrinfo(hostname, port, *, type): + nonlocal call_count + call_count += 1 + resolver_started.set() + assert release_resolver.wait(timeout=5.0) + return [ + (2, 1, 6, "", (PUBLIC_ADDRESS, port)), + (2, 1, 6, "", (SECOND_PUBLIC_ADDRESS, port)), + ] + + monkeypatch.setattr(validation, "_join_dns_resolution_flight", observing_join) + monkeypatch.setattr(validation.socket, "getaddrinfo", two_address_getaddrinfo) + outcomes: queue.Queue[tuple[str, tuple[str, ...] | None]] = queue.Queue() + + def validate_with_limit(limit: int) -> None: + try: + result = validate_egress_url_details( + "https://api.example.com", + policy=_policy(max_resolved_addresses=limit), + ) + except EgressNotAllowedError: + outcomes.put(("denied", None)) + else: + assert result is not None + outcomes.put(("allowed", result.addresses)) + + strict_caller = threading.Thread(target=validate_with_limit, args=(1,)) + broad_caller = threading.Thread(target=validate_with_limit, args=(2,)) + strict_caller.start() + assert resolver_started.wait(timeout=2.0) + broad_caller.start() + assert joiner_arrived.wait(timeout=2.0) + release_resolver.set() + strict_caller.join(timeout=3.0) + broad_caller.join(timeout=3.0) + + assert not strict_caller.is_alive() + assert not broad_caller.is_alive() + assert call_count == 1 + assert sorted(outcomes.get_nowait() for _ in range(2)) == [ + ("allowed", (PUBLIC_ADDRESS, SECOND_PUBLIC_ADDRESS)), + ("denied", None), + ] + assert _active_authority_flight() is None + + +def test_unexpected_shared_resolver_failure_has_no_private_provenance(monkeypatch) -> None: + """Normalize dependency-controlled resolver failures without retaining a cause.""" + + def failing_getaddrinfo(hostname, port, *, type): + assert hostname == AUTHORITY_KEY[0] + assert port == AUTHORITY_KEY[1] + assert type == validation.socket.SOCK_STREAM + raise RuntimeError("private resolver detail") + + monkeypatch.setattr(validation.socket, "getaddrinfo", failing_getaddrinfo) + + with pytest.raises( + EgressNotAllowedError, + match=f"^{EGRESS_NOT_ALLOWED}$", + ) as exc_info: + validate_egress_url_details("https://api.example.com", policy=_policy()) + + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + assert _active_authority_flight() is None From eb4b49dc657d17cf69ba4ffdf091953094c95e01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:36:01 +0900 Subject: [PATCH 05/26] test: contain DNS worker-start failure --- tests/test_dns_worker_start_failure.py | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/test_dns_worker_start_failure.py diff --git a/tests/test_dns_worker_start_failure.py b/tests/test_dns_worker_start_failure.py new file mode 100644 index 00000000..99dd2d5b --- /dev/null +++ b/tests/test_dns_worker_start_failure.py @@ -0,0 +1,45 @@ +"""Regression tests for DNS resolver worker-start failure containment.""" + +from __future__ import annotations + +import pytest + +from egressweave import ( + EGRESS_NOT_ALLOWED, + EgressNotAllowedError, + EgressPolicy, + validation, +) + +_HOSTNAME = "worker-start.example.com" +_PORT = 443 +_AUTHORITY_KEY = (_HOSTNAME, _PORT) +_POLICY = EgressPolicy.from_hosts(_HOSTNAME) + + +def test_dns_worker_start_failure_erases_private_exception_provenance(monkeypatch) -> None: + """Reject thread-start failure without retaining dependency-controlled details.""" + + class _BrokenThread: + """Synthetic resolver thread that fails before any worker can run.""" + + def start(self) -> None: + """Raise one dependency-controlled platform startup failure.""" + raise RuntimeError("private thread-start detail") + + monkeypatch.setattr( + validation.threading, + "Thread", + lambda **kwargs: _BrokenThread(), + ) + + with pytest.raises( + EgressNotAllowedError, + match=f"^{EGRESS_NOT_ALLOWED}$", + ) as exc_info: + validation._resolve_all_global_addresses(_HOSTNAME, _PORT, _POLICY) + + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + with validation._DNS_RESOLUTION_FLIGHTS_LOCK: + assert _AUTHORITY_KEY not in validation._DNS_RESOLUTION_FLIGHTS From eab545e02691883da7c38622bc81a4caf1f302b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:36:39 +0900 Subject: [PATCH 06/26] test: isolate DNS timeout flight cleanup --- tests/test_dns_timeout.py | 46 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/test_dns_timeout.py b/tests/test_dns_timeout.py index 30bd89a3..01bd1585 100644 --- a/tests/test_dns_timeout.py +++ b/tests/test_dns_timeout.py @@ -1,3 +1,4 @@ +import asyncio import math import threading import time @@ -12,6 +13,8 @@ ) from egressweave import validation as v +_AUTHORITY_KEY = ("api.openai.com", 443) + @pytest.mark.parametrize( "timeout", @@ -48,6 +51,18 @@ def fake_getaddrinfo(host, port, type=None): return started, release +def _release_and_wait_for_resolver(release: threading.Event) -> None: + with v._DNS_RESOLUTION_FLIGHTS_LOCK: + flight = v._DNS_RESOLUTION_FLIGHTS.get(_AUTHORITY_KEY) + + assert flight is not None + release.set() + assert flight.completed.wait(timeout=1.0) + + with v._DNS_RESOLUTION_FLIGHTS_LOCK: + assert _AUTHORITY_KEY not in v._DNS_RESOLUTION_FLIGHTS + + def test_sync_validation_enforces_dns_timeout(monkeypatch): started, release = _install_blocking_resolver(monkeypatch) policy = EgressPolicy.from_hosts( @@ -67,7 +82,7 @@ def test_sync_validation_enforces_dns_timeout(monkeypatch): assert started.wait(timeout=0.2) assert time.monotonic() - started_at < 0.5 finally: - release.set() + _release_and_wait_for_resolver(release) async def test_async_validation_uses_same_bounded_dns_timeout(monkeypatch): @@ -89,7 +104,34 @@ async def test_async_validation_uses_same_bounded_dns_timeout(monkeypatch): assert started.wait(timeout=0.2) assert time.monotonic() - started_at < 0.5 finally: - release.set() + _release_and_wait_for_resolver(release) + + +async def test_async_dns_timeout_includes_to_thread_scheduling_delay(monkeypatch): + """Count executor scheduling delay inside the public async DNS deadline.""" + policy = EgressPolicy.from_hosts( + "api.openai.com", + dns_timeout_seconds=0.05, + ) + + async def delayed_to_thread(function, *args): + assert function is v._resolve_all_global_addresses + assert args == ("api.openai.com", 443, policy) + await asyncio.sleep(0.25) + return ("93.184.216.34",) + + monkeypatch.setattr(v.asyncio, "to_thread", delayed_to_thread) + started_at = time.monotonic() + + with pytest.raises( + EgressNotAllowedError, match="^egress URL is not allowed$" + ): + await validate_egress_url_details_async( + "https://api.openai.com/v1", + policy=policy, + ) + + assert time.monotonic() - started_at < 0.2 def test_resolver_failure_remains_generic(monkeypatch): From 7ba891e6f4955cdf9985d2946419ac3ba91fbbf5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:37:32 +0900 Subject: [PATCH 07/26] docs: record DNS single-flight resource boundary --- .../dns-resolution-resource-bounds.md | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/research/dns-resolution-resource-bounds.md diff --git a/docs/research/dns-resolution-resource-bounds.md b/docs/research/dns-resolution-resource-bounds.md new file mode 100644 index 00000000..891b715e --- /dev/null +++ b/docs/research/dns-resolution-resource-bounds.md @@ -0,0 +1,133 @@ +# DNS resolution resource bounds + +## Scope + +EgressWeave resolves an already-authorized canonical `(hostname, port)` before a +pinned transport can connect. DNS remains an untrusted and potentially slow +platform dependency: callers need a finite wait, the process needs a finite +resolver-worker budget, and a slow authority must not multiply background work +merely because several requests arrive together. + +This note records the exact runtime boundary implemented by the DNS validation +layer. It does not make DNS an authorization source and does not replace the +existing all-address scope checks, exact authority policy, transport pinning, or +per-connect address revalidation. + +## Implemented boundary + +The process retains one finite global resolver-worker pool. When overlapping +validations target the same exact authority, they join one live **in-flight** +resolver operation keyed by canonical `(hostname, port)` instead of creating a +new platform resolver thread for every caller. + +The sharing boundary is intentionally live-only: + +- the registry **never caches a completed DNS result**; +- the entry is removed as soon as the worker completes and current waiters are + released; +- a later validation therefore starts a fresh lookup, preserving the product's + DNS rebinding freshness model instead of converting single-flight into a DNS + cache; +- the worker publishes only raw address strings; **each caller retains its own** + finite `dns_timeout_seconds` deadline and independently applies all-address + classification, local-development rules, deduplication, and its own + `max_resolved_addresses` ceiling; +- for asynchronous validation, that caller-owned deadline starts **before executor scheduling** of the synchronous resolver wrapper, so time spent waiting for `asyncio.to_thread(...)` executor capacity counts against `dns_timeout_seconds` instead of extending the public wait budget; +- empty answers, capacity exhaustion, worker-start failure, resolver failure, + caller deadline exhaustion, and incomplete shared outcomes all fail closed; +- unexpected resolver exceptions are normalized to the public generic denial + without retaining dependency-controlled `__cause__` or `__context__` text. + +This arrangement reduces same-authority amplification without weakening policy +isolation. A permissive caller cannot donate its larger address cardinality or +local-address scope to a stricter caller that happened to share the same raw DNS +lookup. + +## Residual platform limitation + +The standard-library resolver path ultimately calls `socket.getaddrinfo`, whose +work can block in the operating-system or C-library resolver. EgressWeave runs +that call on a daemon thread so the caller can stop waiting at its configured +deadline, but Python does not provide a safe general operation for forcibly +terminating arbitrary already-running thread work. EgressWeave therefore +**cannot safely cancel** an already-running platform `socket.getaddrinfo` call. + +For the asynchronous API, `asyncio.wait_for(...)` bounds the caller-visible +operation beginning before executor scheduling. Cancellation of that await does +not imply that a resolver wrapper or platform resolver already running on a +thread has been force-stopped. The same live-only single-flight and finite global +resolver-slot rules remain the resource backstop after a caller stops waiting. + +A timed-out resolver worker may remain alive and keep one global resolver slot +until the platform call returns. Single-flight prevents repeated callers for one +slow authority from consuming additional slots, but distinct stalled +authorities can still occupy the finite global pool. Once capacity is exhausted, +new validations fail closed rather than creating unbounded resolver work. The +caller timeout consequently bounds caller waiting time; it does not claim to +bound the operating-system resolver's lifetime. + +This residual is deliberate. The package does not interrupt Python threads, +ship a recursive DNS resolver, silently detach unbounded work, or persist a +completed result merely to avoid another lookup. Those alternatives would +introduce larger correctness, portability, security, or DNS rebinding risks. + +## Security interpretation + +The original failure mode is an asymmetric resource-consumption problem: a +small number of repeated validations could leave disproportionate live resolver +work after the initiating callers had already failed. CWE-405 describes this +amplification family, while CWE-410 describes exhaustion of a finite resource +pool and CWE-400 is the broader uncontrolled-resource-consumption class. The +runtime fix both meters the global resource and collapses duplicate +same-authority live work. + +These CWE entries are diagnostic taxonomy, not proof of vulnerability severity. +CWE-400 is a high-level class and MITRE discourages mapping to overly broad +entries when a more specific weakness is available. + +## Operational expectations + +Host applications and operators should preserve these assumptions: + +1. A DNS timeout is a caller-wait bound, not a guarantee that the platform + resolver thread has terminated. For async validation the bound includes + executor scheduling delay before the synchronous wrapper starts. +2. Repeated generic DNS denials or resolver-capacity pressure should be measured + outside the core library using purpose-limited counters; raw candidate URLs, + credentials, resolved IP addresses, and resolver exception text should not be + added to default telemetry. +3. Resolver availability, operating-system DNS configuration, recursive-server + behavior, and network reachability remain host/platform responsibilities. +4. EgressWeave must continue to re-resolve after a completed flight. Persisting + completed DNS results would change the reviewed DNS rebinding and freshness + boundary and requires a separate architecture decision. +5. A future resolver implementation must preserve exact authority identity, + finite concurrency, per-caller policy validation, generic failure behavior, + and fresh post-completion resolution before it can replace this boundary. + +RFC 8305 treats hostname resolution and subsequent connection concurrency as +separate stages and explicitly accounts for DNS answers changing during +connection setup. EgressWeave does not claim RFC 8305 defines its single-flight +mechanism; the RFC is relevant because the live-only design avoids turning a +resource-control optimization into a persistent answer cache that would erase +fresh DNS observations. + +## References + +MITRE. (2026). *CWE-400: Uncontrolled resource consumption (Version 4.20)*. +https://cwe.mitre.org/data/definitions/400.html + +MITRE. (2026). *CWE-405: Asymmetric resource consumption (amplification) +(Version 4.20)*. https://cwe.mitre.org/data/definitions/405.html + +MITRE. (2026). *CWE-410: Insufficient resource pool (Version 4.20)*. +https://cwe.mitre.org/data/definitions/410.html + +Python Software Foundation. (2026). *socket — Low-level networking interface +(Python 3.13.14 documentation)*. https://docs.python.org/3.13/library/socket.html + +Python Software Foundation. (2026). *threading — Thread-based parallelism +(Python 3.13.14 documentation)*. https://docs.python.org/3.13/library/threading.html + +Schinazi, D., & Pauly, T. (2017). *Happy Eyeballs Version 2: Better connectivity +using concurrency* (RFC 8305). RFC Editor. https://doi.org/10.17487/RFC8305 From 62bae2fa4ca091fa91aab7bea0222d52f8d2380e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:38:07 +0900 Subject: [PATCH 08/26] test: bind DNS single-flight documentation contracts --- ...s_resolution_singleflight_documentation.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/test_dns_resolution_singleflight_documentation.py diff --git a/tests/test_dns_resolution_singleflight_documentation.py b/tests/test_dns_resolution_singleflight_documentation.py new file mode 100644 index 00000000..6df4ab52 --- /dev/null +++ b/tests/test_dns_resolution_singleflight_documentation.py @@ -0,0 +1,70 @@ +"""Documentation contracts for bounded same-authority DNS single-flight work.""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +RESEARCH_PATH = REPOSITORY_ROOT / "docs" / "research" / "dns-resolution-resource-bounds.md" +CHANGELOG_PATH = REPOSITORY_ROOT / "CHANGELOG.md" + + +def _normalized_section(path: Path, heading: str) -> str: + """Return one named Markdown section with whitespace normalized.""" + text = path.read_text(encoding="utf-8") + marker = f"{heading}\n" + section = text.split(marker, 1)[1] + level = len(heading) - len(heading.lstrip("#")) + next_heading = re.search(rf"\n#{{1,{level}}} ", section) + if next_heading is not None: + section = section[: next_heading.start()] + return " ".join(section.split()) + + +def test_dns_singleflight_operator_guidance_records_the_exact_boundary() -> None: + """Require operator guidance for live-only sharing and per-caller validation.""" + guidance = _normalized_section(RESEARCH_PATH, "## Implemented boundary") + + required = ( + "in-flight", + "(hostname, port)", + "never caches a completed DNS result", + "each caller retains its own", + "max_resolved_addresses", + "before executor scheduling", + ) + missing = [fragment for fragment in required if fragment not in guidance] + assert not missing, f"DNS single-flight implemented boundary is missing: {missing}" + + +def test_dns_singleflight_operator_guidance_records_residual_platform_limits() -> None: + """Keep non-cancellable platform-resolver limits in their canonical section.""" + guidance = _normalized_section(RESEARCH_PATH, "## Residual platform limitation") + + required = ("socket.getaddrinfo", "cannot safely cancel", "DNS rebinding") + missing = [fragment for fragment in required if fragment not in guidance] + assert not missing, f"DNS residual platform guidance is missing: {missing}" + + +def test_changelog_records_same_authority_dns_worker_deduplication() -> None: + """Keep the buyer-facing release history aligned with the resolver repair.""" + changelog = _normalized_section(CHANGELOG_PATH, "### Security") + + assert "same-authority DNS" in changelog + assert "in-flight" in changelog + assert "completed DNS results" in changelog + + +def test_operator_guidance_counts_async_executor_scheduling_inside_deadline() -> None: + """State that async timeout accounting starts before executor scheduling.""" + guidance = _normalized_section(RESEARCH_PATH, "## Implemented boundary") + + assert "before executor scheduling" in guidance + + +def test_changelog_records_async_executor_scheduling_deadline() -> None: + """Keep the async DNS deadline tightening visible in release history.""" + changelog = _normalized_section(CHANGELOG_PATH, "### Security") + + assert "executor scheduling" in changelog From 6056ff1a1702792768bb85a84ada6c33e114ebb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:41:40 +0900 Subject: [PATCH 09/26] docs: record DNS single-flight release history --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e9dc0c..5c4ad153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). without changing the centrally managed review-agent credential contract. ### Security +- Deduplicate overlapping same-authority DNS resolution through one live + in-flight `(hostname, port)` worker while preserving the finite global + resolver ceiling, per-caller deadlines, and caller-specific address policy. + Completed DNS results are never cached, later validations perform a fresh + lookup, and unexpected resolver failures remain behind the generic denial + boundary without private exception provenance. Asynchronous validation starts + its caller-owned deadline before executor scheduling, so `asyncio.to_thread` + queue delay cannot extend the configured public DNS wait budget. - Canonicalize the public manifest writer's optional `forbidden_root` before any output-parent creation or output-path access. Missing, non-directory, symlinked, unresolvable, or otherwise noncanonical roots now fail with one @@ -361,4 +369,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 6ddef023d07027c6d92c400d3b6674ab39a66be5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:42:44 +0900 Subject: [PATCH 10/26] chore: preserve changelog newline --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4ad153..e4b874dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -369,4 +369,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. \ No newline at end of file + container case, DNS-to-private rejection, and transport pinning. From 35649392e7ec0e85fc141c195e279eb64d75e162 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:44:02 +0900 Subject: [PATCH 11/26] test: align DNS changelog contract with canonical prose --- tests/test_dns_resolution_singleflight_documentation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dns_resolution_singleflight_documentation.py b/tests/test_dns_resolution_singleflight_documentation.py index 6df4ab52..7e4a33e8 100644 --- a/tests/test_dns_resolution_singleflight_documentation.py +++ b/tests/test_dns_resolution_singleflight_documentation.py @@ -53,7 +53,7 @@ def test_changelog_records_same_authority_dns_worker_deduplication() -> None: assert "same-authority DNS" in changelog assert "in-flight" in changelog - assert "completed DNS results" in changelog + assert "Completed DNS results" in changelog def test_operator_guidance_counts_async_executor_scheduling_inside_deadline() -> None: From 231177a707f74fc85ab34bb5ba5a633c46814dfa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:52:10 +0900 Subject: [PATCH 12/26] test: reproduce non-RuntimeError DNS worker-start leak --- tests/test_dns_worker_start_failure.py | 81 +++++++++++++++++++++----- 1 file changed, 67 insertions(+), 14 deletions(-) diff --git a/tests/test_dns_worker_start_failure.py b/tests/test_dns_worker_start_failure.py index 99dd2d5b..e7e84e1d 100644 --- a/tests/test_dns_worker_start_failure.py +++ b/tests/test_dns_worker_start_failure.py @@ -17,29 +17,82 @@ _POLICY = EgressPolicy.from_hosts(_HOSTNAME) -def test_dns_worker_start_failure_erases_private_exception_provenance(monkeypatch) -> None: - """Reject thread-start failure without retaining dependency-controlled details.""" +class _CountingResolutionSlots: + """Record resolver-slot acquisitions and releases for start-failure tests.""" + + def __init__(self) -> None: + self.acquire_count = 0 + self.release_count = 0 + + def acquire(self, *, timeout: float) -> bool: + """Record one successful slot acquisition.""" + assert timeout > 0 + self.acquire_count += 1 + return True + + def release(self) -> None: + """Record one resolver-slot release.""" + self.release_count += 1 + + +class _SyntheticThreadStartFailure(BaseException): + """Model a non-RuntimeError failure before the resolver worker starts.""" + + +def _install_failing_resolver_thread(monkeypatch, failure: BaseException) -> None: + """Fail only EgressWeave's resolver thread while preserving other threads.""" + original_thread = validation.threading.Thread class _BrokenThread: """Synthetic resolver thread that fails before any worker can run.""" def start(self) -> None: - """Raise one dependency-controlled platform startup failure.""" - raise RuntimeError("private thread-start detail") + """Raise the configured platform startup failure.""" + raise failure - monkeypatch.setattr( - validation.threading, - "Thread", - lambda **kwargs: _BrokenThread(), - ) + def selective_thread(*args, **kwargs): + if kwargs.get("name") == "egressweave-dns-resolver": + return _BrokenThread() + return original_thread(*args, **kwargs) + + monkeypatch.setattr(validation.threading, "Thread", selective_thread) - with pytest.raises( - EgressNotAllowedError, - match=f"^{EGRESS_NOT_ALLOWED}$", - ) as exc_info: - validation._resolve_all_global_addresses(_HOSTNAME, _PORT, _POLICY) + +def _assert_generic_start_failure(monkeypatch, failure: BaseException) -> None: + """Require one failed worker start to release its slot and flight.""" + slots = _CountingResolutionSlots() + monkeypatch.setattr(validation, "_DNS_RESOLUTION_SLOTS", slots) + _install_failing_resolver_thread(monkeypatch, failure) + + try: + with pytest.raises( + EgressNotAllowedError, + match=f"^{EGRESS_NOT_ALLOWED}$", + ) as exc_info: + validation._resolve_all_global_addresses(_HOSTNAME, _PORT, _POLICY) + finally: + with validation._DNS_RESOLUTION_FLIGHTS_LOCK: + validation._DNS_RESOLUTION_FLIGHTS.pop(_AUTHORITY_KEY, None) assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None + assert slots.acquire_count == 1 + assert slots.release_count == slots.acquire_count with validation._DNS_RESOLUTION_FLIGHTS_LOCK: assert _AUTHORITY_KEY not in validation._DNS_RESOLUTION_FLIGHTS + + +def test_dns_worker_start_runtime_failure_erases_private_provenance(monkeypatch) -> None: + """Normalize the documented RuntimeError startup failure and clean resources.""" + _assert_generic_start_failure( + monkeypatch, + RuntimeError("private thread-start detail"), + ) + + +def test_dns_worker_start_non_runtime_failure_fails_closed(monkeypatch) -> None: + """Clean resources when a direct non-RuntimeError failure prevents startup.""" + _assert_generic_start_failure( + monkeypatch, + _SyntheticThreadStartFailure("private thread-start detail"), + ) From ed6b52a715e44a86d020bce717361a0799dc740e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 11:26:53 +0900 Subject: [PATCH 13/26] fix: contain all DNS worker-start failures --- src/egressweave/validation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/egressweave/validation.py b/src/egressweave/validation.py index e2f29fcb..a4cf048c 100644 --- a/src/egressweave/validation.py +++ b/src/egressweave/validation.py @@ -319,7 +319,7 @@ def resolve_on_worker() -> None: worker_start_failed = False try: worker.start() - except RuntimeError as exc: + except BaseException as exc: _DNS_RESOLUTION_SLOTS.release() flight.error = exc _complete_dns_resolution_flight(key, flight) @@ -534,4 +534,4 @@ async def validate_egress_url_async( validated = await validate_egress_url_details_async(value, policy=policy) if validated is None: return None - return validated.normalized_url + return validated.normalized_url \ No newline at end of file From 1189af5f6921f0aad966521eea48e12d1d06f8b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 11:31:33 +0900 Subject: [PATCH 14/26] test: keep DNS startup failures in ordinary exception boundary --- tests/test_dns_worker_start_failure.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_dns_worker_start_failure.py b/tests/test_dns_worker_start_failure.py index e7e84e1d..84664f8d 100644 --- a/tests/test_dns_worker_start_failure.py +++ b/tests/test_dns_worker_start_failure.py @@ -35,11 +35,11 @@ def release(self) -> None: self.release_count += 1 -class _SyntheticThreadStartFailure(BaseException): - """Model a non-RuntimeError failure before the resolver worker starts.""" +class _SyntheticThreadStartFailure(Exception): + """Model a non-RuntimeError ordinary failure before the resolver worker starts.""" -def _install_failing_resolver_thread(monkeypatch, failure: BaseException) -> None: +def _install_failing_resolver_thread(monkeypatch, failure: Exception) -> None: """Fail only EgressWeave's resolver thread while preserving other threads.""" original_thread = validation.threading.Thread @@ -58,7 +58,7 @@ def selective_thread(*args, **kwargs): monkeypatch.setattr(validation.threading, "Thread", selective_thread) -def _assert_generic_start_failure(monkeypatch, failure: BaseException) -> None: +def _assert_generic_start_failure(monkeypatch, failure: Exception) -> None: """Require one failed worker start to release its slot and flight.""" slots = _CountingResolutionSlots() monkeypatch.setattr(validation, "_DNS_RESOLUTION_SLOTS", slots) @@ -91,7 +91,7 @@ def test_dns_worker_start_runtime_failure_erases_private_provenance(monkeypatch) def test_dns_worker_start_non_runtime_failure_fails_closed(monkeypatch) -> None: - """Clean resources when a direct non-RuntimeError failure prevents startup.""" + """Clean resources when an ordinary non-RuntimeError failure prevents startup.""" _assert_generic_start_failure( monkeypatch, _SyntheticThreadStartFailure("private thread-start detail"), From edb2e51ccd83197252cf628e38a73a79be7dd90b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 11:32:45 +0900 Subject: [PATCH 15/26] fix: preserve ordinary DNS startup exception boundary --- src/egressweave/validation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/egressweave/validation.py b/src/egressweave/validation.py index a4cf048c..7f16c2ae 100644 --- a/src/egressweave/validation.py +++ b/src/egressweave/validation.py @@ -319,7 +319,7 @@ def resolve_on_worker() -> None: worker_start_failed = False try: worker.start() - except BaseException as exc: + except Exception as exc: # noqa: BLE001 _DNS_RESOLUTION_SLOTS.release() flight.error = exc _complete_dns_resolution_flight(key, flight) @@ -534,4 +534,4 @@ async def validate_egress_url_async( validated = await validate_egress_url_details_async(value, policy=policy) if validated is None: return None - return validated.normalized_url \ No newline at end of file + return validated.normalized_url From 4ff0f10bb4058fd8dcb7973c63a923daa717f9a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 11:37:01 +0900 Subject: [PATCH 16/26] test: remove superseded DNS single-flight RED duplicate --- ...ns_resolution_singleflight_current_main.py | 118 ------------------ 1 file changed, 118 deletions(-) delete mode 100644 tests/test_dns_resolution_singleflight_current_main.py diff --git a/tests/test_dns_resolution_singleflight_current_main.py b/tests/test_dns_resolution_singleflight_current_main.py deleted file mode 100644 index 230a4690..00000000 --- a/tests/test_dns_resolution_singleflight_current_main.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Current-main RED contract for bounded same-authority DNS resolver work.""" - -from __future__ import annotations - -import queue -import threading -import time - -from egressweave import ( - EGRESS_NOT_ALLOWED, - EgressNotAllowedError, - EgressPolicy, - validate_egress_url_details, - validation, -) - -AUTHORITY = ("api.example.com", 443) -PUBLIC_ADDRESS = "93.184.216.34" -CALLER_COUNT = 4 -DNS_TIMEOUT_SECONDS = 0.10 - - -class _CountingResolutionSlots: - """Allow resolver work while recording acquired and released worker slots.""" - - def __init__(self) -> None: - self.acquire_count = 0 - self.release_count = 0 - self._condition = threading.Condition() - - def acquire(self, *, timeout: float) -> bool: - """Record one successful resolver-slot acquisition.""" - assert timeout > 0 - with self._condition: - self.acquire_count += 1 - self._condition.notify_all() - return True - - def release(self) -> None: - """Record one resolver-slot release.""" - with self._condition: - self.release_count += 1 - self._condition.notify_all() - - def wait_until_balanced(self, timeout: float) -> bool: - """Wait until every acquired resolver slot has been released.""" - deadline = time.monotonic() + timeout - with self._condition: - while self.release_count < self.acquire_count: - remaining = deadline - time.monotonic() - if remaining <= 0: - return False - self._condition.wait(remaining) - return True - - -def test_same_authority_timeouts_share_one_live_resolver(monkeypatch) -> None: - """Overlapping callers must not multiply one slow authority's resolver work.""" - slots = _CountingResolutionSlots() - release_resolver = threading.Event() - call_lock = threading.Lock() - caller_barrier = threading.Barrier(CALLER_COUNT + 1) - outcomes: queue.Queue[str] = queue.Queue() - call_count = 0 - - def slow_getaddrinfo(hostname, port, *, type): - nonlocal call_count - assert (hostname, port) == AUTHORITY - assert type == validation.socket.SOCK_STREAM - with call_lock: - call_count += 1 - if not release_resolver.wait(timeout=5.0): - raise RuntimeError("test resolver was not released") - return [(2, 1, 6, "", (PUBLIC_ADDRESS, port))] - - monkeypatch.setattr(validation.socket, "getaddrinfo", slow_getaddrinfo) - monkeypatch.setattr(validation, "_DNS_RESOLUTION_SLOTS", slots) - policy = EgressPolicy.from_hosts( - AUTHORITY[0], - dns_timeout_seconds=DNS_TIMEOUT_SECONDS, - ) - - def validate_from_caller() -> None: - try: - caller_barrier.wait(timeout=5.0) - validate_egress_url_details( - f"https://{AUTHORITY[0]}", - policy=policy, - ) - except EgressNotAllowedError as exc: - outcomes.put(str(exc)) - except Exception as exc: # noqa: BLE001 - preserve thread diagnostics - outcomes.put(f"unexpected {type(exc).__name__}: {exc}") - else: - outcomes.put("unexpected success") - - callers = [ - threading.Thread(target=validate_from_caller, name=f"dns-caller-{index}") - for index in range(CALLER_COUNT) - ] - for caller in callers: - caller.start() - - try: - caller_barrier.wait(timeout=5.0) - for caller in callers: - caller.join(timeout=DNS_TIMEOUT_SECONDS + 2.0) - - assert not any(caller.is_alive() for caller in callers) - assert sorted(outcomes.get_nowait() for _ in range(CALLER_COUNT)) == [ - EGRESS_NOT_ALLOWED - ] * CALLER_COUNT - with call_lock: - assert call_count == 1 - assert slots.acquire_count == 1 - finally: - release_resolver.set() - assert slots.wait_until_balanced(timeout=3.0) From 9df8f282373c3c84c407196cbb80312dd7afcd5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 14:38:45 +0900 Subject: [PATCH 17/26] test: require DNS worker helper documentation --- tests/test_dns_singleflight_docstrings.py | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/test_dns_singleflight_docstrings.py diff --git a/tests/test_dns_singleflight_docstrings.py b/tests/test_dns_singleflight_docstrings.py new file mode 100644 index 00000000..50a708db --- /dev/null +++ b/tests/test_dns_singleflight_docstrings.py @@ -0,0 +1,26 @@ +"""Documentation contracts for DNS single-flight production helpers.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +VALIDATION_SOURCE = Path("src/egressweave/validation.py") + + +def test_dns_singleflight_worker_has_beginner_readable_docstring() -> None: + """Keep the resolver worker helper understandable at the production boundary.""" + module = ast.parse(VALIDATION_SOURCE.read_text(encoding="utf-8")) + worker = next( + node + for node in ast.walk(module) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "resolve_on_worker" + ) + + docstring = ast.get_docstring(worker) + assert docstring is not None + normalized = docstring.lower() + assert "resolver" in normalized or "address" in normalized + assert "shared" in normalized From eb76f1ae954f141dad77dc7e66b83756a59c35d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 14:41:07 +0900 Subject: [PATCH 18/26] docs(code): document DNS single-flight worker --- src/egressweave/validation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/egressweave/validation.py b/src/egressweave/validation.py index 7f16c2ae..70d82a9c 100644 --- a/src/egressweave/validation.py +++ b/src/egressweave/validation.py @@ -303,6 +303,7 @@ def _resolve_all_global_addresses( raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) def resolve_on_worker() -> None: + """Publish one shared resolver address flight and release its worker slot.""" try: flight.raw_addresses = _resolve_raw_addresses_blocking(hostname, port) except Exception as exc: # noqa: BLE001 From 21e3072f7758b6f88203e2eba9d0eda9727a07c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 14:45:34 +0900 Subject: [PATCH 19/26] test: fix DNS docstring contract import spacing --- tests/test_dns_singleflight_docstrings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_dns_singleflight_docstrings.py b/tests/test_dns_singleflight_docstrings.py index 50a708db..a1af9f8a 100644 --- a/tests/test_dns_singleflight_docstrings.py +++ b/tests/test_dns_singleflight_docstrings.py @@ -5,7 +5,6 @@ import ast from pathlib import Path - VALIDATION_SOURCE = Path("src/egressweave/validation.py") From f8c6b44d802e295d942fb399b53ad97756d226c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:43:11 +0900 Subject: [PATCH 20/26] test: expose DNS start interrupt cleanup leak --- tests/test_dns_worker_start_failure.py | 27 +++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/test_dns_worker_start_failure.py b/tests/test_dns_worker_start_failure.py index 84664f8d..add7a57f 100644 --- a/tests/test_dns_worker_start_failure.py +++ b/tests/test_dns_worker_start_failure.py @@ -39,7 +39,11 @@ class _SyntheticThreadStartFailure(Exception): """Model a non-RuntimeError ordinary failure before the resolver worker starts.""" -def _install_failing_resolver_thread(monkeypatch, failure: Exception) -> None: +class _SyntheticThreadStartInterrupt(BaseException): + """Model a process-control interruption before the resolver worker starts.""" + + +def _install_failing_resolver_thread(monkeypatch, failure: BaseException) -> None: """Fail only EgressWeave's resolver thread while preserving other threads.""" original_thread = validation.threading.Thread @@ -96,3 +100,24 @@ def test_dns_worker_start_non_runtime_failure_fails_closed(monkeypatch) -> None: monkeypatch, _SyntheticThreadStartFailure("private thread-start detail"), ) + + +def test_dns_worker_start_base_exception_cleans_before_propagating(monkeypatch) -> None: + """Release owned resolver state before propagating process-control failures.""" + slots = _CountingResolutionSlots() + failure = _SyntheticThreadStartInterrupt("synthetic process-control interruption") + monkeypatch.setattr(validation, "_DNS_RESOLUTION_SLOTS", slots) + _install_failing_resolver_thread(monkeypatch, failure) + + try: + with pytest.raises(_SyntheticThreadStartInterrupt) as exc_info: + validation._resolve_all_global_addresses(_HOSTNAME, _PORT, _POLICY) + + assert exc_info.value is failure + assert slots.acquire_count == 1 + assert slots.release_count == slots.acquire_count + with validation._DNS_RESOLUTION_FLIGHTS_LOCK: + assert _AUTHORITY_KEY not in validation._DNS_RESOLUTION_FLIGHTS + finally: + with validation._DNS_RESOLUTION_FLIGHTS_LOCK: + validation._DNS_RESOLUTION_FLIGHTS.pop(_AUTHORITY_KEY, None) From df9b86250fb6745714708177ce6c0bd7613b38a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:48:14 +0900 Subject: [PATCH 21/26] fix: clean DNS worker start interrupts --- src/egressweave/validation.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/egressweave/validation.py b/src/egressweave/validation.py index 70d82a9c..82be11dc 100644 --- a/src/egressweave/validation.py +++ b/src/egressweave/validation.py @@ -317,15 +317,15 @@ def resolve_on_worker() -> None: name="egressweave-dns-resolver", daemon=True, ) - worker_start_failed = False try: worker.start() - except Exception as exc: # noqa: BLE001 + except BaseException as exc: _DNS_RESOLUTION_SLOTS.release() - flight.error = exc + if isinstance(exc, Exception): + flight.error = exc _complete_dns_resolution_flight(key, flight) - worker_start_failed = True - if worker_start_failed: + if not isinstance(exc, Exception): + raise raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None remaining_timeout = deadline - time.monotonic() From 884a59376d1b8d8e79f32418831f388cfa258fa5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:51:28 +0900 Subject: [PATCH 22/26] fix: preserve generic DNS startup denials --- src/egressweave/validation.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/egressweave/validation.py b/src/egressweave/validation.py index 82be11dc..5e8abde5 100644 --- a/src/egressweave/validation.py +++ b/src/egressweave/validation.py @@ -317,15 +317,18 @@ def resolve_on_worker() -> None: name="egressweave-dns-resolver", daemon=True, ) + worker_start_failed = False try: worker.start() except BaseException as exc: _DNS_RESOLUTION_SLOTS.release() if isinstance(exc, Exception): flight.error = exc + worker_start_failed = True _complete_dns_resolution_flight(key, flight) - if not isinstance(exc, Exception): + if not worker_start_failed: raise + if worker_start_failed: raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None remaining_timeout = deadline - time.monotonic() From 8302ca851dd66b536aedd157d9723aa5f646cb42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:09:43 +0900 Subject: [PATCH 23/26] test(dns): forbid direct BaseException worker catch --- tests/test_dns_worker_start_failure.py | 31 +++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/tests/test_dns_worker_start_failure.py b/tests/test_dns_worker_start_failure.py index add7a57f..b30bb769 100644 --- a/tests/test_dns_worker_start_failure.py +++ b/tests/test_dns_worker_start_failure.py @@ -2,6 +2,10 @@ from __future__ import annotations +import ast +import inspect +import textwrap + import pytest from egressweave import ( @@ -39,10 +43,6 @@ class _SyntheticThreadStartFailure(Exception): """Model a non-RuntimeError ordinary failure before the resolver worker starts.""" -class _SyntheticThreadStartInterrupt(BaseException): - """Model a process-control interruption before the resolver worker starts.""" - - def _install_failing_resolver_thread(monkeypatch, failure: BaseException) -> None: """Fail only EgressWeave's resolver thread while preserving other threads.""" original_thread = validation.threading.Thread @@ -102,15 +102,30 @@ def test_dns_worker_start_non_runtime_failure_fails_closed(monkeypatch) -> None: ) -def test_dns_worker_start_base_exception_cleans_before_propagating(monkeypatch) -> None: - """Release owned resolver state before propagating process-control failures.""" +def test_dns_worker_start_avoids_direct_base_exception_handler() -> None: + """Keep worker-start cleanup explicit without a catch-all BaseException handler.""" + source = textwrap.dedent(inspect.getsource(validation._resolve_all_global_addresses)) + syntax_tree = ast.parse(source) + direct_base_exception_handlers = [ + handler + for node in ast.walk(syntax_tree) + if isinstance(node, ast.Try) + for handler in node.handlers + if isinstance(handler.type, ast.Name) and handler.type.id == "BaseException" + ] + + assert direct_base_exception_handlers == [] + + +def test_dns_worker_start_interrupt_cleans_before_propagating(monkeypatch) -> None: + """Release owned resolver state before propagating a real process-control interrupt.""" slots = _CountingResolutionSlots() - failure = _SyntheticThreadStartInterrupt("synthetic process-control interruption") + failure = KeyboardInterrupt("synthetic process-control interruption") monkeypatch.setattr(validation, "_DNS_RESOLUTION_SLOTS", slots) _install_failing_resolver_thread(monkeypatch, failure) try: - with pytest.raises(_SyntheticThreadStartInterrupt) as exc_info: + with pytest.raises(KeyboardInterrupt) as exc_info: validation._resolve_all_global_addresses(_HOSTNAME, _PORT, _POLICY) assert exc_info.value is failure From ec7ac4ca8badb634eab4849d24f05d415aaf2603 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:12:53 +0900 Subject: [PATCH 24/26] fix(dns): narrow worker start exception handling --- src/egressweave/validation.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/egressweave/validation.py b/src/egressweave/validation.py index 5e8abde5..4e3fcf22 100644 --- a/src/egressweave/validation.py +++ b/src/egressweave/validation.py @@ -320,14 +320,15 @@ def resolve_on_worker() -> None: worker_start_failed = False try: worker.start() - except BaseException as exc: + except Exception as exc: # noqa: BLE001 + _DNS_RESOLUTION_SLOTS.release() + flight.error = exc + worker_start_failed = True + _complete_dns_resolution_flight(key, flight) + except (KeyboardInterrupt, SystemExit, GeneratorExit): _DNS_RESOLUTION_SLOTS.release() - if isinstance(exc, Exception): - flight.error = exc - worker_start_failed = True _complete_dns_resolution_flight(key, flight) - if not worker_start_failed: - raise + raise if worker_start_failed: raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None @@ -538,4 +539,4 @@ async def validate_egress_url_async( validated = await validate_egress_url_details_async(value, policy=policy) if validated is None: return None - return validated.normalized_url + return validated.normalized_url \ No newline at end of file From b81af75e84abe5964edce9dafc7bebc9ee44dd2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:22:15 +0900 Subject: [PATCH 25/26] test(dns): reject bare worker-start catch-all --- tests/test_dns_worker_start_failure.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_dns_worker_start_failure.py b/tests/test_dns_worker_start_failure.py index b30bb769..a2e5bb79 100644 --- a/tests/test_dns_worker_start_failure.py +++ b/tests/test_dns_worker_start_failure.py @@ -103,7 +103,7 @@ def test_dns_worker_start_non_runtime_failure_fails_closed(monkeypatch) -> None: def test_dns_worker_start_avoids_direct_base_exception_handler() -> None: - """Keep worker-start cleanup explicit without a catch-all BaseException handler.""" + """Keep worker-start cleanup explicit without a catch-all exception handler.""" source = textwrap.dedent(inspect.getsource(validation._resolve_all_global_addresses)) syntax_tree = ast.parse(source) direct_base_exception_handlers = [ @@ -111,7 +111,11 @@ def test_dns_worker_start_avoids_direct_base_exception_handler() -> None: for node in ast.walk(syntax_tree) if isinstance(node, ast.Try) for handler in node.handlers - if isinstance(handler.type, ast.Name) and handler.type.id == "BaseException" + if handler.type is None + or ( + isinstance(handler.type, ast.Name) + and handler.type.id == "BaseException" + ) ] assert direct_base_exception_handlers == [] From 2a5ae0a87c49bd0533c5b86a2f01c032dd4ef6f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:05:09 +0900 Subject: [PATCH 26/26] docs: restore DNS single-flight release history --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f61c04..2678ec7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). disable the recurring loop. ### Security +- Deduplicate overlapping same-authority DNS resolution through one live + in-flight `(hostname, port)` worker while preserving the finite global + resolver ceiling, per-caller deadlines, and caller-specific address policy. + Completed DNS results are never cached, later validations perform a fresh + lookup, and unexpected resolver failures remain behind the generic denial + boundary without private exception provenance. Asynchronous validation starts + its caller-owned deadline before executor scheduling, so `asyncio.to_thread` + queue delay cannot extend the configured public DNS wait budget. - Pin the credential-free verifier to a reviewed Python 3.13 `python@sha256:<64-hex>` digest, validate it before Docker execution, and remove mutable-tag and `RepoDigests` promotion from the verifier boundary.