diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f61c04..580e60cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). disable the recurring loop. ### Security +- Stabilize special-purpose remote-address classification across supported + Python runtimes with a narrow reviewed source-controlled compatibility overlay; + explicit globally reachable exceptions remain allowed only after every normal + EgressWeave authority check, and no runtime registry download is introduced. - 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. diff --git a/docs/research/README.md b/docs/research/README.md index 814ad0cb..91b47500 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -149,6 +149,17 @@ Primary references: transport, re-validating immediately before each connect and refusing any host/port drift. The `Host` header is rewritten to the validated netloc. +## Version-stable special-purpose address classification + +Supported CPython releases have not always agreed on every special-purpose +range. EgressWeave therefore applies a narrow reviewed compatibility overlay to +parsed remote addresses before the interpreter's version-sensitive +classification, while keeping globally reachable exceptions explicit and +leaving local-development authority unchanged. The overlay is source-controlled, +not a mutable registry mirror, and adds no runtime network dependency. See +[version-stable special-purpose address classification](special-purpose-address-classification.md) +for the current IANA/CPython boundary, update procedure, and primary references. + ## Staggered concurrent connection — RFC 8305 (Happy Eyeballs) When a validated hostname yields several addresses, RFC 8305 section 5 advises diff --git a/docs/research/special-purpose-address-classification.md b/docs/research/special-purpose-address-classification.md new file mode 100644 index 00000000..5e69ac6f --- /dev/null +++ b/docs/research/special-purpose-address-classification.md @@ -0,0 +1,54 @@ +# Version-stable special-purpose address classification + +EgressWeave supports more than one CPython minor and patch line. Security policy must therefore not silently widen or narrow merely because a deployment uses a runtime whose `ipaddress` module carries older special-purpose registry tables. Remote-address validation applies a small **reviewed compatibility overlay** to the parsed address value before consulting the running interpreter's version-sensitive classification properties. + +This overlay is deliberately narrow. It freezes only reviewed differences that matter to EgressWeave's fail-closed remote-egress decision; it is not a private replacement for the IANA registries, and there is **no runtime registry download** or other network dependency in address validation. New or changed assignments require a normal source change, test-first evidence, current primary-source review, and the complete repository gate set. + +## Policy boundary + +The compatibility layer preserves the current reviewed remote-egress classification for ranges whose behavior changed across otherwise supported interpreter versions. As revalidated against the live IANA registries and Python 3.14.6 documentation on 2026-08-11, the bounded set is: + +| Address or range | EgressWeave remote decision | Current evidence | +|---|---|---| +| `192.0.0.0/24` | deny, except the two explicit globally reachable anycast addresses below | IANA IPv4 Special-Purpose Address Space and Python `ipaddress` | +| `192.0.0.9/32` | allow when every other EgressWeave authority check succeeds | IANA `Globally Reachable=True` exception | +| `192.0.0.10/32` | allow when every other EgressWeave authority check succeeds | IANA `Globally Reachable=True` exception | +| `64:ff9b:1::/48` | deny | IANA IPv6 Special-Purpose Address Space and Python `ipaddress` | +| `100:0:0:1::/64` | deny | IANA IPv6 Special-Purpose Address Space | +| `2001::/23` | deny except the reviewed globally reachable subranges below | IANA IPv6 Special-Purpose Address Space and Python `ipaddress` | +| `2001:1::1/128`, `2001:1::2/128`, `2001:1::3/128` | allow when every other EgressWeave authority check succeeds | IANA globally reachable exceptions; `2001:1::3/128` is explicit because the live registry is newer than the exception list currently described in Python documentation | +| `2001:3::/32`, `2001:4:112::/48`, `2001:20::/28`, `2001:30::/28` | allow when every other EgressWeave authority check succeeds | IANA globally reachable exceptions | +| `2001:2::/48` | deny | IANA IPv6 Special-Purpose Address Space | +| `2002::/16` | deny as the reviewed EgressWeave/CPython compatibility policy | Python 3.13+ classifies 6to4 as private; the IANA registry's `Globally Reachable` value is not a simple `False`, so this row must not be represented as an IANA-denial claim | +| `3fff::/20` | deny | IANA documentation prefix, not globally reachable | +| `5f00::/16` | deny | IANA Segment Routing SIDs prefix, not globally reachable | + +The broad-network rule is evaluated after explicit globally reachable exceptions, so an exception cannot be swallowed by its parent `192.0.0.0/24` or `2001::/23` compatibility range. Outside the reviewed overlay, EgressWeave continues to apply the running interpreter's `is_private`, `is_loopback`, `is_link_local`, `is_reserved`, `is_unspecified`, `is_multicast`, and `is_global` properties as defense in depth. + +`2002::/16` is intentionally called out because registry and library vocabularies are not perfectly interchangeable. Python 3.14.6 documents that Python 3.13 changed `2002::/16` to `is_private=True`. EgressWeave adopts that reviewed CPython fail-closed behavior for consistent supported-runtime decisions without making a stronger IANA claim than the live registry supports. + +## Local-development boundary + +The compatibility overlay does not grant local authority. `allow_local=True` still requires the exact allowlisted local hostname and port and retains the existing loopback/private-container restrictions. IP-literal URLs remain forbidden. An address made globally acceptable by an explicit compatibility exception still has to pass every ordinary hostname, port, method, DNS-pinning, transport, TLS, request, and resource policy check. + +## Update procedure + +A future registry or CPython change is a security-policy change, not a data refresh. Maintainers must: + +1. compare the live IANA IPv4 and IPv6 special-purpose registries with the supported Python documentation and relevant CPython change; +2. add deterministic RED coverage that simulates the older interpreter property values while asserting the intended EgressWeave decision from the parsed address itself; +3. add the smallest explicit range or exception required, with broader parent ranges evaluated only after specific global exceptions; +4. update this note and release-facing history without overclaiming IANA semantics; and +5. run the complete supported-Python matrix, 100% production statement/branch coverage, package acceptance, security scans, exact-head automated review, independent approval where required, and repository protections. + +A mutable registry fetch at request time would make the security boundary non-reproducible and could change destination authority without a repository diff, so it remains out of scope. + +## References + +Internet Assigned Numbers Authority. (2025, October 9). *IANA IPv4 Special-Purpose Address Space*. Retrieved August 11, 2026, from https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml + +Internet Assigned Numbers Authority. (2025, October 9). *IANA IPv6 Special-Purpose Address Space*. Retrieved August 11, 2026, from https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml + +Python Software Foundation. (2026). *ipaddress — IPv4/IPv6 manipulation library* (Python 3.14.6 documentation). https://docs.python.org/3.14/library/ipaddress.html + +Stasiak, J. (2024, March 22). *GH-113171: Fix “private” (non-global) IP address ranges* [Commit `40d75c2`]. CPython. https://github.com/python/cpython/commit/40d75c2b7f5c67e254d0a025e0f2e2c7ada7f69f diff --git a/src/egressweave/validation.py b/src/egressweave/validation.py index 9542ab9e..9f40d443 100644 --- a/src/egressweave/validation.py +++ b/src/egressweave/validation.py @@ -41,6 +41,26 @@ ipaddress.ip_network("192.168.0.0/16"), ipaddress.ip_network("fc00::/7"), ) +_SPECIAL_PURPOSE_GLOBAL_EXCEPTIONS = ( + ipaddress.ip_network("192.0.0.9/32"), + ipaddress.ip_network("192.0.0.10/32"), + ipaddress.ip_network("2001:1::1/128"), + ipaddress.ip_network("2001:1::2/128"), + ipaddress.ip_network("2001:1::3/128"), + ipaddress.ip_network("2001:3::/32"), + ipaddress.ip_network("2001:4:112::/48"), + ipaddress.ip_network("2001:20::/28"), + ipaddress.ip_network("2001:30::/28"), +) +_SPECIAL_PURPOSE_NON_GLOBAL_NETWORKS = ( + ipaddress.ip_network("192.0.0.0/24"), + ipaddress.ip_network("64:ff9b:1::/48"), + ipaddress.ip_network("100:0:0:1::/64"), + ipaddress.ip_network("2001::/23"), + ipaddress.ip_network("2002::/16"), + ipaddress.ip_network("3fff::/20"), + ipaddress.ip_network("5f00::/16"), +) class EgressNotAllowedError(ValueError): @@ -156,6 +176,19 @@ def _is_private_local_address( return any(ip_address in network for network in _PRIVATE_LOCAL_NETWORKS) +def _special_purpose_global_override( + ip_address: ipaddress.IPv4Address | ipaddress.IPv6Address, +) -> bool | None: + """Return a reviewed current-registry override for version-sensitive ranges.""" + if isinstance(ip_address, ipaddress.IPv6Address) and ip_address.ipv4_mapped: + return _special_purpose_global_override(ip_address.ipv4_mapped) + if any(ip_address in network for network in _SPECIAL_PURPOSE_GLOBAL_EXCEPTIONS): + return True + if any(ip_address in network for network in _SPECIAL_PURPOSE_NON_GLOBAL_NETWORKS): + return False + return None + + def _format_normalized_netloc(hostname: str, port: int, *, explicit_port: bool) -> str: """Render one canonical URL authority while preserving explicit-port intent.""" host_part = f"[{hostname}]" if ":" in hostname else hostname @@ -173,6 +206,8 @@ def _validate_global_address( local names may resolve only to loopback addresses. An explicitly allowlisted single-label container name may resolve only to loopback or private network space. IP-literal URLs and dotted remote hosts never inherit either exception. + Reviewed special-purpose registry corrections are applied before the running + interpreter's version-sensitive ``ipaddress`` classification for remote hosts. """ try: ip_address = ipaddress.ip_address(address) @@ -189,6 +224,12 @@ def _validate_global_address( raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) return str(ip_address) + special_purpose_global = _special_purpose_global_override(ip_address) + if special_purpose_global is False: + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) + if special_purpose_global is True: + return str(ip_address) + if ( ip_address.is_private or ip_address.is_loopback diff --git a/tests/test_special_address_registry_compatibility.py b/tests/test_special_address_registry_compatibility.py new file mode 100644 index 00000000..12199d07 --- /dev/null +++ b/tests/test_special_address_registry_compatibility.py @@ -0,0 +1,147 @@ +"""Regression tests for version-stable special-purpose address classification.""" + +from __future__ import annotations + +import ipaddress + +import pytest + +from egressweave import EgressPolicy +from egressweave.validation import EgressNotAllowedError, _validate_global_address + +_REMOTE_POLICY = EgressPolicy.from_hosts("api.example.com") + + +def _force_address_properties( + monkeypatch: pytest.MonkeyPatch, + address_type: type[ipaddress.IPv4Address | ipaddress.IPv6Address], + *, + private: bool, + global_: bool, +) -> None: + """Replace version-sensitive stdlib address properties for one test.""" + monkeypatch.setattr(address_type, "is_private", property(lambda self: private)) + monkeypatch.setattr(address_type, "is_global", property(lambda self: global_)) + monkeypatch.setattr(address_type, "is_loopback", property(lambda self: False)) + monkeypatch.setattr(address_type, "is_link_local", property(lambda self: False)) + monkeypatch.setattr(address_type, "is_reserved", property(lambda self: False)) + monkeypatch.setattr(address_type, "is_unspecified", property(lambda self: False)) + monkeypatch.setattr(address_type, "is_multicast", property(lambda self: False)) + + +@pytest.mark.parametrize( + ("address", "address_type"), + [ + ("192.0.0.8", ipaddress.IPv4Address), + ("64:ff9b:1::1", ipaddress.IPv6Address), + ("100:0:0:1::1", ipaddress.IPv6Address), + ("2001:2::1", ipaddress.IPv6Address), + ("2002::1", ipaddress.IPv6Address), + ("3fff::1", ipaddress.IPv6Address), + ("5f00::1", ipaddress.IPv6Address), + ], +) +def test_reviewed_non_global_compatibility_ranges_remain_denied_when_stdlib_says_global( + monkeypatch: pytest.MonkeyPatch, + address: str, + address_type: type[ipaddress.IPv4Address | ipaddress.IPv6Address], +) -> None: + """Deny reviewed compatibility ranges independently of stdlib patch data.""" + _force_address_properties( + monkeypatch, + address_type, + private=False, + global_=True, + ) + + with pytest.raises(EgressNotAllowedError, match="egress URL is not allowed"): + _validate_global_address(address, _REMOTE_POLICY, hostname="api.example.com") + + +@pytest.mark.parametrize( + ("address", "address_type"), + [ + ("192.0.0.9", ipaddress.IPv4Address), + ("192.0.0.10", ipaddress.IPv4Address), + ("2001:1::1", ipaddress.IPv6Address), + ("2001:1::2", ipaddress.IPv6Address), + ("2001:1::3", ipaddress.IPv6Address), + ("2001:3::1", ipaddress.IPv6Address), + ("2001:4:112::1", ipaddress.IPv6Address), + ("2001:20::1", ipaddress.IPv6Address), + ("2001:30::1", ipaddress.IPv6Address), + ], +) +def test_current_iana_global_exceptions_remain_allowed_when_stdlib_parent_is_private( + monkeypatch: pytest.MonkeyPatch, + address: str, + address_type: type[ipaddress.IPv4Address | ipaddress.IPv6Address], +) -> None: + """Honor explicit IANA-global exceptions despite stale parent classification.""" + _force_address_properties( + monkeypatch, + address_type, + private=True, + global_=False, + ) + + assert ( + _validate_global_address(address, _REMOTE_POLICY, hostname="api.example.com") + == address + ) + + +def test_ipv4_mapped_non_global_overlay_uses_underlying_ipv4_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Deny mapped special-purpose IPv4 even when stale IPv6 metadata says global.""" + _force_address_properties( + monkeypatch, + ipaddress.IPv6Address, + private=False, + global_=True, + ) + + with pytest.raises(EgressNotAllowedError, match="egress URL is not allowed"): + _validate_global_address( + "::ffff:192.0.0.8", + _REMOTE_POLICY, + hostname="api.example.com", + ) + + +def test_ipv4_mapped_global_exception_uses_underlying_ipv4_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Allow mapped reviewed IPv4 exception despite stale IPv6 private metadata.""" + _force_address_properties( + monkeypatch, + ipaddress.IPv6Address, + private=True, + global_=False, + ) + address = "::ffff:192.0.0.9" + + assert _validate_global_address( + address, + _REMOTE_POLICY, + hostname="api.example.com", + ) == str(ipaddress.ip_address(address)) + + +@pytest.mark.parametrize("address", ["192.0.0.9", "2001:1::1"]) +@pytest.mark.parametrize( + ("hostname", "policy"), + [ + ("localhost", EgressPolicy.from_hosts([], allow_local=True)), + ("ollama", EgressPolicy.from_hosts("ollama", allow_local=True)), + ], +) +def test_global_compatibility_exceptions_do_not_widen_local_hostname_authority( + address: str, + hostname: str, + policy: EgressPolicy, +) -> None: + """Keep local-development address scope narrower than remote compatibility.""" + with pytest.raises(EgressNotAllowedError, match="egress URL is not allowed"): + _validate_global_address(address, policy, hostname=hostname) diff --git a/tests/test_special_address_registry_documentation.py b/tests/test_special_address_registry_documentation.py new file mode 100644 index 00000000..9cc09a83 --- /dev/null +++ b/tests/test_special_address_registry_documentation.py @@ -0,0 +1,66 @@ +"""Contracts keeping special-purpose address policy and shipped guidance aligned.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GUIDE = ROOT / "docs" / "research" / "special-purpose-address-classification.md" +RESEARCH_INDEX = ROOT / "docs" / "research" / "README.md" +CHANGELOG = ROOT / "CHANGELOG.md" +VALIDATION = ROOT / "src" / "egressweave" / "validation.py" + + +def test_special_purpose_policy_has_discoverable_current_primary_source_guidance() -> None: + """Keep the compatibility policy discoverable and grounded in current sources.""" + guide = GUIDE.read_text(encoding="utf-8") + index = RESEARCH_INDEX.read_text(encoding="utf-8") + + for fragment in ( + "Version-stable special-purpose address classification", + "no runtime registry download", + "IANA IPv4 Special-Purpose Address Space", + "IANA IPv6 Special-Purpose Address Space", + "Python 3.14.6", + "40d75c2b7f5c67e254d0a025e0f2e2c7ada7f69f", + "2001:1::3/128", + "2002::/16", + ): + assert fragment in guide + + assert "special-purpose-address-classification.md" in index + + +def test_release_history_records_version_stable_address_hardening() -> None: + """Keep buyer-facing unreleased history aligned with the security change.""" + changelog = CHANGELOG.read_text(encoding="utf-8") + + assert "Stabilize special-purpose remote-address classification" in changelog + assert "no runtime registry download" in changelog + + +def test_documented_compatibility_ranges_match_source_controlled_overlay() -> None: + """Prevent silent drift between the reviewed guide and implementation constants.""" + guide = GUIDE.read_text(encoding="utf-8") + source = VALIDATION.read_text(encoding="utf-8") + + for network in ( + "192.0.0.0/24", + "192.0.0.9/32", + "192.0.0.10/32", + "64:ff9b:1::/48", + "100:0:0:1::/64", + "2001::/23", + "2001:1::1/128", + "2001:1::2/128", + "2001:1::3/128", + "2001:3::/32", + "2001:4:112::/48", + "2001:20::/28", + "2001:30::/28", + "2002::/16", + "3fff::/20", + "5f00::/16", + ): + assert network in guide + assert network in source