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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/research/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions docs/research/special-purpose-address-classification.md
Original file line number Diff line number Diff line change
@@ -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 documentation on 2026-08-14, 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` remains explicit because the live registry is newer than some supported-runtime tables |
| `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 | Supported Python lines classify 6to4 conservatively; the IANA registry's `Globally Reachable` value is not a simple `False`, so this row is not 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. IPv4-mapped IPv6 addresses recursively inherit the reviewed decision for the underlying IPv4 address. 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. EgressWeave adopts the 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 supported Python documentation and relevant CPython changes;
2. add deterministic RED coverage that simulates 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 14, 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 14, 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 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
41 changes: 41 additions & 0 deletions src/egressweave/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
147 changes: 147 additions & 0 deletions tests/test_special_address_registry_compatibility.py
Original file line number Diff line number Diff line change
@@ -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)
63 changes: 63 additions & 0 deletions tests/test_special_address_registry_documentation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""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"
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 documentation",
"40d75c2b7f5c67e254d0a025e0f2e2c7ada7f69f",
"2001:1::3/128",
"2002::/16",
):
assert fragment in guide

assert "special-purpose-address-classification.md" in index


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")

source_controlled_networks = (
"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",
)
for network in source_controlled_networks:
assert network in guide
assert network in source

# The guide calls out this IANA child allocation explicitly, while the
# implementation denies it through the reviewed broader 2001::/23 parent.
assert "2001:2::/48" in guide
assert "2001::/23" in source
Loading