From 10569788099d0eb869befa65003b2d47f7d8c9c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 06:33:33 +0900 Subject: [PATCH 1/4] test(security): reproduce hostile request-extension get dispatch --- .../test_request_timeout_untrusted_objects.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_request_timeout_untrusted_objects.py b/tests/test_request_timeout_untrusted_objects.py index e686e7ed..811040c4 100644 --- a/tests/test_request_timeout_untrusted_objects.py +++ b/tests/test_request_timeout_untrusted_objects.py @@ -33,6 +33,29 @@ def __len__(self) -> int: return 1 +class _ExplodingExtensionsGetMapping(Mapping[str, object]): + """Expose safe items while making direct ``get`` dispatch attacker-controlled.""" + + def __getitem__(self, key: str) -> object: + """Return one ordinary timeout mapping through indexed access.""" + if key == "timeout": + return {"connect": 1.0} + raise KeyError(key) + + def __iter__(self) -> Iterator[str]: + """Advertise the single reviewed request-extension key.""" + return iter(("timeout",)) + + def __len__(self) -> int: + """Report the one advertised extension key.""" + return 1 + + def get(self, key: str, default: object = None) -> object: + """Raise if production code dynamically dispatches untrusted ``get``.""" + del key, default + return _raise_unexpected_protocol_failure("secret extensions get failure") + + class _ExplodingReal: """Behave as a registered real number whose conversion raises arbitrarily.""" @@ -71,6 +94,21 @@ def _assert_generic_timeout_denial(timeout_value: object) -> None: assert error.value.__context__ is None +def test_request_extensions_get_exceptions_are_masked() -> None: + """Do not dispatch an untrusted outer mapping's ``get`` implementation.""" + with pytest.raises( + EgressNotAllowedError, + match=f"^{EGRESS_NOT_ALLOWED}$", + ) as error: + _bind_bounded_request_timeouts( + _ExplodingExtensionsGetMapping(), + EgressTimeoutPolicy(), + ) + + assert error.value.__cause__ is None + assert error.value.__context__ is None + + def test_timeout_mapping_exceptions_are_masked() -> None: """Mask arbitrary failures raised while copying an untrusted timeout mapping.""" _assert_generic_timeout_denial(_ExplodingTimeoutMapping()) From c758f11764456d9584175d5f2e806ffd6ab4c1dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 06:35:46 +0900 Subject: [PATCH 2/4] fix(security): snapshot request extensions before timeout access --- src/egressweave/request_safety.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/egressweave/request_safety.py b/src/egressweave/request_safety.py index 0447ddbf..fd647790 100644 --- a/src/egressweave/request_safety.py +++ b/src/egressweave/request_safety.py @@ -260,13 +260,20 @@ def _bind_bounded_request_timeouts( every phase after the destination has already passed policy validation. Missing or disabled values therefore receive the immutable policy maximum; stricter non-negative finite values are preserved and larger values are - capped. Malformed maps, unknown keys, booleans, negative numbers, and + capped. The outer mapping is detached exactly once before timeout lookup so + caller-controlled ``get`` methods cannot cross the generic denial boundary + and a stateful mapping cannot present different extension snapshots during + one decision. Malformed maps, unknown keys, booleans, negative numbers, and non-finite values fail through the generic policy boundary before HTTPCore can allocate a connection or wait on network I/O. Failures raised by attacker-controlled mapping, key-comparison, or numeric protocol methods are also masked. """ - raw_timeout = extensions.get("timeout") + safe_extensions = _copy_request_extensions(extensions) + if safe_extensions is None: + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None + + raw_timeout = safe_extensions.get("timeout") if raw_timeout is None: requested_timeouts: dict[object, object] | None = {} elif isinstance(raw_timeout, Mapping): @@ -299,7 +306,6 @@ def _bind_bounded_request_timeouts( raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None bounded_timeouts[key] = min(normalized_value, maximum) - safe_extensions = dict(extensions) safe_extensions["timeout"] = bounded_timeouts return safe_extensions From 38b6e4c561d4e5fbf2a339254e5363d9f681d22c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 06:38:23 +0900 Subject: [PATCH 3/4] test(security): preserve valid mapping compatibility after snapshot --- .../test_request_timeout_untrusted_objects.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_request_timeout_untrusted_objects.py b/tests/test_request_timeout_untrusted_objects.py index 811040c4..b959ff1f 100644 --- a/tests/test_request_timeout_untrusted_objects.py +++ b/tests/test_request_timeout_untrusted_objects.py @@ -94,19 +94,19 @@ def _assert_generic_timeout_denial(timeout_value: object) -> None: assert error.value.__context__ is None -def test_request_extensions_get_exceptions_are_masked() -> None: - """Do not dispatch an untrusted outer mapping's ``get`` implementation.""" - with pytest.raises( - EgressNotAllowedError, - match=f"^{EGRESS_NOT_ALLOWED}$", - ) as error: - _bind_bounded_request_timeouts( - _ExplodingExtensionsGetMapping(), - EgressTimeoutPolicy(), - ) - - assert error.value.__cause__ is None - assert error.value.__context__ is None +def test_request_extensions_get_is_not_dynamically_dispatched() -> None: + """Snapshot a valid outer mapping without invoking its hostile ``get`` method.""" + bounded = _bind_bounded_request_timeouts( + _ExplodingExtensionsGetMapping(), + EgressTimeoutPolicy(), + ) + + assert bounded["timeout"] == { + "connect": 1.0, + "read": 5.0, + "write": 5.0, + "pool": 5.0, + } def test_timeout_mapping_exceptions_are_masked() -> None: From b7021ef272170664abd81c22b38a2a5e39bfe497 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 06:39:58 +0900 Subject: [PATCH 4/4] test(security): cover hostile outer extension snapshot failure --- .../test_request_timeout_untrusted_objects.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_request_timeout_untrusted_objects.py b/tests/test_request_timeout_untrusted_objects.py index b959ff1f..b96832fd 100644 --- a/tests/test_request_timeout_untrusted_objects.py +++ b/tests/test_request_timeout_untrusted_objects.py @@ -33,6 +33,23 @@ def __len__(self) -> int: return 1 +class _ExplodingExtensionsCopyMapping(Mapping[str, object]): + """Raise while the untrusted outer request-extension mapping is detached.""" + + def __getitem__(self, key: str) -> object: + """Delegate indexed access to an unexpected secret-bearing failure.""" + del key + return _raise_unexpected_protocol_failure("secret extensions copy failure") + + def __iter__(self) -> Iterator[str]: + """Advertise one ordinary reviewed extension key.""" + return iter(("timeout",)) + + def __len__(self) -> int: + """Report the one advertised extension key.""" + return 1 + + class _ExplodingExtensionsGetMapping(Mapping[str, object]): """Expose safe items while making direct ``get`` dispatch attacker-controlled.""" @@ -94,6 +111,21 @@ def _assert_generic_timeout_denial(timeout_value: object) -> None: assert error.value.__context__ is None +def test_request_extension_copy_exceptions_are_masked() -> None: + """Mask arbitrary failures while detaching the outer extension mapping.""" + with pytest.raises( + EgressNotAllowedError, + match=f"^{EGRESS_NOT_ALLOWED}$", + ) as error: + _bind_bounded_request_timeouts( + _ExplodingExtensionsCopyMapping(), + EgressTimeoutPolicy(), + ) + + assert error.value.__cause__ is None + assert error.value.__context__ is None + + def test_request_extensions_get_is_not_dynamically_dispatched() -> None: """Snapshot a valid outer mapping without invoking its hostile ``get`` method.""" bounded = _bind_bounded_request_timeouts(