mirror of
https://github.com/LearningCircuit/local-deep-research.git
synced 2026-06-16 03:51:07 +03:00
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:
18
SECURITY.md
18
SECURITY.md
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user