security: extend NAT64-wrapped IMDS protection to notification validator + SECURITY.md

Parity follow-up so the two SSRF gates make the same "ALWAYS_BLOCKED"
promise about cloud-metadata IPs.

- notification_validator._ip_matches_blocked_range now does the same
  embedded-IPv4 extraction as ssrf_validator.is_ip_blocked: when an
  IPv6 destination falls in a NAT64 prefix, the low 32 bits are
  matched against ALWAYS_BLOCKED_METADATA_IPS before the
  security.allow_nat64 carve-out is consulted. Without this, an
  operator running with both LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true
  and LDR_SECURITY_ALLOW_NAT64=true could configure a webhook URL
  like [64:ff9b::a9fe:a9fe] and reach IMDS via the notification path.

- 11 new tests under TestNat64EnvOptOutInNotificationValidator
  covering: env unset blocks NAT64; env=true allows non-metadata
  NAT64 wraps; env=true keeps IMDS / ECS / Alibaba metadata blocked
  via WKP and RFC 8215 local-use wraps; env=true does NOT unblock
  6to4/Teredo; the DNS-resolved branch of _is_private_ip also
  enforces the embedded-IPv4 check.

- SECURITY.md "Cloud Metadata Endpoint Block List" gains a paragraph
  documenting the IPv6-wrapped extraction. New "IPv6 Transition
  Prefix Block List" section documents the five blocked prefixes
  (including the new RFC 8215 entry) and the LDR_SECURITY_ALLOW_NAT64
  opt-in for IPv6-only deployments.
This commit is contained in:
LearningCircuit
2026-05-09 21:50:57 +02:00
parent 97115c86d0
commit 8708778f65
3 changed files with 153 additions and 2 deletions

View File

@@ -150,8 +150,26 @@ Both `ssrf_validator.validate_url` and `NotificationURLValidator.validate_servic
| `169.254.0.23` | Tencent Cloud |
| `100.100.100.200` | AlibabaCloud |
The block also catches IPv6-wrapped forms of these metadata IPs. When an IPv6 destination falls in a NAT64 prefix (`64:ff9b::/96` RFC 6052 well-known or `64:ff9b:1::/48` RFC 8215 local-use), the validator extracts the embedded IPv4 from the low 32 bits and matches it against this set — so `[64:ff9b::a9fe:a9fe]` cannot reach `169.254.169.254` even on a host with NAT64 routes configured. The check fires before any opt-in carve-out, so the operator switch described below cannot license IMDS exposure.
Future contributors must not remove entries from this set. Adding a new cloud provider's metadata IP is encouraged when a new public-cloud target appears.
### IPv6 Transition Prefix Block List
`PRIVATE_IP_RANGES` blocks four IPv6 prefixes that can wrap private-IPv4 destinations on hosts with kernel transition routes configured:
| Prefix | Purpose | RFC |
| --- | --- | --- |
| `2002::/16` | 6to4 | RFC 3056 (deprecated by RFC 7526) |
| `64:ff9b::/96` | NAT64 well-known prefix | RFC 6052 |
| `64:ff9b:1::/48` | NAT64 local-use prefix | RFC 8215 |
| `2001::/32` | Teredo | RFC 4380 |
| `100::/64` | IPv6 discard prefix | RFC 6666 |
Default Linux has no `sit0` / NAT64 routes so this is defensive-only on the typical deployment, but blocking these prefixes closes the IPv6-wrapped SSRF bypass class on hosts where transition tunnels are enabled.
Operators on IPv6-only deployments using DNS64+NAT64 (AWS / GKE / Azure IPv6-only nodes) reach IPv4 services through `64:ff9b::/96`. They can opt back into NAT64 reachability via the env-only setting `security.allow_nat64` (`LDR_SECURITY_ALLOW_NAT64=true`). The opt-in is scoped strictly to the two NAT64 prefixes — 6to4, Teredo, and discard remain unconditionally blocked because they have no live legitimate use, and the IMDS embedded-IPv4 check above still applies so cloud metadata stays unreachable through any NAT64 wrap.
URL rejection log lines route through `ssrf_validator.redact_url_for_log` to drop userinfo (RFC 3986 §3.2.1 allows credentials in the URL), path, and query — operators see `scheme://host:port` only. Operators with grep/regex tooling on the rejection log lines will see authority-only strings instead of full URLs.
## Supported Versions

View File

@@ -18,7 +18,11 @@ from .ip_ranges import (
PRIVATE_IP_RANGES as _PRIVATE_IP_RANGES,
NAT64_PREFIXES,
)
from .ssrf_validator import RFC_FORBIDDEN_URL_CHARS_RE, redact_url_for_log
from .ssrf_validator import (
ALWAYS_BLOCKED_METADATA_IPS,
RFC_FORBIDDEN_URL_CHARS_RE,
redact_url_for_log,
)
class NotificationURLValidationError(ValueError):
@@ -70,10 +74,26 @@ class NotificationURLValidator:
def _ip_matches_blocked_range(ip) -> bool:
"""Membership check that honors the security.allow_nat64 env carve-out.
Read the env var lazily so monkeypatching works in tests and so
operator changes don't require a process restart."""
operator changes don't require a process restart.
Mirrors the IMDS embedded-IPv4 extraction in
ssrf_validator.is_ip_blocked: even with the NAT64 opt-in, an IPv6
destination wrapping a cloud-metadata IPv4 in its low 32 bits is
always blocked. The metadata block list is "always" by design;
the operator opt-in for IPv4 reachability does not extend to it.
"""
from ..settings.env_registry import get_env_setting
nat64_allowed = bool(get_env_setting("security.allow_nat64", False))
if isinstance(ip, ipaddress.IPv6Address):
for nat64_prefix in NAT64_PREFIXES:
if ip in nat64_prefix:
embedded_v4 = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
if str(embedded_v4) in ALWAYS_BLOCKED_METADATA_IPS:
return True
break
for network in NotificationURLValidator.PRIVATE_IP_RANGES:
if ip in network:
if nat64_allowed and network in NAT64_PREFIXES:

View File

@@ -524,3 +524,116 @@ class TestClassConstants:
assert "127.0.0.0/8" in range_strings
assert "10.0.0.0/8" in range_strings
assert "192.168.0.0/16" in range_strings
class TestNat64EnvOptOutInNotificationValidator:
"""Mirror of ssrf_validator's TestNat64EnvOptOut for the notification
path. The notification validator must honor the same operator
opt-in semantics AND keep the cloud-metadata block absolute."""
def test_nat64_wkp_blocked_when_env_unset(self, monkeypatch):
monkeypatch.delenv("LDR_SECURITY_ALLOW_NAT64", raising=False)
# 64:ff9b::a00:1 is the NAT64 wrap of 10.0.0.1.
assert NotificationURLValidator._is_private_ip("64:ff9b::a00:1") is True
def test_nat64_wkp_allowed_when_env_true(self, monkeypatch):
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
# NAT64 wrap of 8.8.8.8 — canonical IPv6-only-deployment use case.
assert (
NotificationURLValidator._is_private_ip("64:ff9b::808:808") is False
)
def test_nat64_local_use_allowed_when_env_true(self, monkeypatch):
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert (
NotificationURLValidator._is_private_ip("64:ff9b:1::808:808")
is False
)
def test_imds_via_nat64_wkp_wrap_blocked_under_env_true(self, monkeypatch):
"""[64:ff9b::a9fe:a9fe] — NAT64 WKP wrap of 169.254.169.254.
Must remain blocked even with the operator opt-in. Mirrors the
ssrf_validator embedded-IPv4 IMDS check."""
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert (
NotificationURLValidator._is_private_ip("64:ff9b::a9fe:a9fe")
is True
)
def test_imds_via_nat64_local_use_wrap_blocked_under_env_true(
self, monkeypatch
):
"""Same lock-in for the RFC 8215 local-use prefix wrap."""
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert (
NotificationURLValidator._is_private_ip("64:ff9b:1::a9fe:a9fe")
is True
)
def test_ecs_metadata_via_nat64_wrap_blocked_under_env_true(
self, monkeypatch
):
"""169.254.170.2 = 0xa9feaa02 — AWS ECS task metadata v3."""
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert (
NotificationURLValidator._is_private_ip("64:ff9b::a9fe:aa02")
is True
)
def test_alibaba_metadata_via_nat64_wrap_blocked_under_env_true(
self, monkeypatch
):
"""100.100.100.200 = 0x646464c8 — AlibabaCloud metadata."""
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert (
NotificationURLValidator._is_private_ip("64:ff9b::6464:64c8")
is True
)
def test_env_does_not_unblock_6to4_in_notification_path(self, monkeypatch):
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert (
NotificationURLValidator._is_private_ip("2002:c0a8:101::") is True
)
def test_env_does_not_unblock_teredo_in_notification_path(
self, monkeypatch
):
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert NotificationURLValidator._is_private_ip("2001::1") is True
def test_imds_via_nat64_wrap_blocked_when_env_unset(self, monkeypatch):
"""Sanity: the IMDS embedded-IPv4 check fires regardless of env
state — when env is unset, the NAT64 prefix entry already blocks
directly, but the embedded-IPv4 path is still well-formed."""
monkeypatch.delenv("LDR_SECURITY_ALLOW_NAT64", raising=False)
assert (
NotificationURLValidator._is_private_ip("64:ff9b::a9fe:a9fe")
is True
)
def test_dns_resolved_imds_via_nat64_blocked_under_env_true(
self, monkeypatch
):
"""Hostname-resolution branch: a hostname that resolves to a
NAT64-wrapped IMDS IPv4 must still be blocked under env opt-in.
This exercises the second call site of _ip_matches_blocked_range."""
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
# AF_INET6 result tuple: (family, type, proto, canonname, sockaddr)
# sockaddr for IPv6 is (host, port, flowinfo, scopeid)
with patch(
"socket.getaddrinfo",
return_value=[
(
socket.AF_INET6,
socket.SOCK_STREAM,
6,
"",
("64:ff9b::a9fe:a9fe", 0, 0, 0),
)
],
):
assert (
NotificationURLValidator._is_private_ip("imds.attacker.example")
is True
)