Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c5d0142
test: reproduce special-purpose IP classification drift
seonghobae Aug 11, 2026
48626ea
fix: stabilize special-purpose IP egress policy
seonghobae Aug 11, 2026
f88e8d9
docs: record special-purpose address compatibility policy
seonghobae Aug 11, 2026
31e922a
docs: index special-purpose address policy
seonghobae Aug 11, 2026
f0920a9
docs: record special-purpose address hardening in release history
seonghobae Aug 11, 2026
d27ef15
test: bind special-purpose address policy documentation
seonghobae Aug 11, 2026
015b026
test: keep special-purpose exceptions out of local authority
seonghobae Aug 11, 2026
a77772d
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 11, 2026
45a0a4f
merge: update special-address policy on current main
seonghobae Aug 11, 2026
76f512c
Merge branch 'main' of https://github.com/ContextualWisdomLab/EgressW…
seonghobae Aug 11, 2026
dbb6f98
test: keep special-address PR lint-clean
seonghobae Aug 11, 2026
8065fc4
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 11, 2026
b75a030
test(security): cover mapped IPv4 registry compatibility
seonghobae Aug 11, 2026
1fa903e
Merge branch 'main' into security/reconstruct-special-purpose-50a84d
opencode-agent[bot] Aug 11, 2026
5f01348
fix: apply registry overlay to mapped IPv4 addresses
seonghobae Aug 11, 2026
22a1b0d
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 11, 2026
1b7be98
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 11, 2026
b8629e9
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 11, 2026
2ce54e9
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 11, 2026
7421130
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 12, 2026
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
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.6 documentation on 2026-08-11, the bounded set is:
Comment thread
seonghobae marked this conversation as resolved.

| 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
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)
66 changes: 66 additions & 0 deletions tests/test_special_address_registry_documentation.py
Original file line number Diff line number Diff line change
@@ -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
Loading