Skip to content

Commit c1b0396

Browse files
authored
Block IPv6 transition SSRF bypasses (#4426)
1 parent 522ed5b commit c1b0396

2 files changed

Lines changed: 106 additions & 30 deletions

File tree

fastmcp_slim/fastmcp/server/auth/ssrf.py

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,23 @@
2222

2323
logger = get_logger(__name__)
2424

25-
NAT64_WELL_KNOWN_PREFIX = ipaddress.ip_network("64:ff9b::/96")
25+
NAT64_PREFIXES: tuple[
26+
tuple[ipaddress.IPv6Network, tuple[tuple[int, int, int, int], ...]], ...
27+
] = (
28+
(ipaddress.IPv6Network("64:ff9b::/96"), ((12, 13, 14, 15),)),
29+
(
30+
ipaddress.IPv6Network("64:ff9b:1::/48"),
31+
(
32+
(6, 7, 9, 10),
33+
(7, 9, 10, 11),
34+
(9, 10, 11, 12),
35+
(12, 13, 14, 15),
36+
),
37+
),
38+
)
39+
LOW32_OFFSETS = (12, 13, 14, 15)
40+
IPV4_TRANSLATED_PREFIX = ipaddress.IPv6Network("0:0:0:0:ffff:0:0:0/96")
41+
ISATAP_INTERFACE_IDS = (b"\x00\x00\x5e\xfe", b"\x02\x00\x5e\xfe")
2642

2743

2844
def format_ip_for_url(ip_str: str) -> str:
@@ -54,6 +70,39 @@ class SSRFFetchError(Exception):
5470
"""Raised when SSRF-safe fetch fails."""
5571

5672

73+
def _embedded_ipv4_addresses(
74+
ip: ipaddress.IPv6Address,
75+
) -> set[ipaddress.IPv4Address]:
76+
"""Return IPv4 addresses embedded in known IPv6 transition forms."""
77+
candidates: set[ipaddress.IPv4Address] = set()
78+
packed = ip.packed
79+
80+
def from_offsets(offsets: tuple[int, int, int, int]) -> ipaddress.IPv4Address:
81+
return ipaddress.IPv4Address(bytes(packed[i] for i in offsets))
82+
83+
if ip.ipv4_mapped:
84+
candidates.add(ip.ipv4_mapped)
85+
if ip.sixtofour:
86+
candidates.add(ip.sixtofour)
87+
if ip.teredo:
88+
server, client = ip.teredo
89+
candidates.update((server, client))
90+
if ip in IPV4_TRANSLATED_PREFIX:
91+
candidates.add(from_offsets(LOW32_OFFSETS))
92+
93+
for prefix, offset_options in NAT64_PREFIXES:
94+
if ip in prefix:
95+
candidates.update(from_offsets(offsets) for offsets in offset_options)
96+
97+
if int(ip) >> 32 == 0 and not ip.is_loopback and not ip.is_unspecified:
98+
candidates.add(from_offsets(LOW32_OFFSETS))
99+
100+
if packed[8:12] in ISATAP_INTERFACE_IDS:
101+
candidates.add(from_offsets(LOW32_OFFSETS))
102+
103+
return candidates
104+
105+
57106
def is_ip_allowed(ip_str: str) -> bool:
58107
"""Check if an IP address is allowed (must be globally routable unicast).
59108
@@ -63,7 +112,7 @@ def is_ip_allowed(ip_str: str) -> bool:
63112
- Link-local (169.254.x, fe80::) - includes AWS metadata!
64113
- Reserved, unspecified
65114
- RFC6598 Carrier-Grade NAT (100.64.0.0/10) - can point to internal networks
66-
- NAT64 (64:ff9b::/96) - can point to internal networks
115+
- IPv6 transition forms that embed blocked IPv4 targets
67116
68117
Additionally blocks multicast addresses (not caught by is_global).
69118
@@ -78,26 +127,18 @@ def is_ip_allowed(ip_str: str) -> bool:
78127
except ValueError:
79128
return False
80129

130+
if isinstance(ip, ipaddress.IPv6Address):
131+
if any(
132+
not is_ip_allowed(str(embedded_ip))
133+
for embedded_ip in _embedded_ipv4_addresses(ip)
134+
):
135+
return False
136+
81137
if not ip.is_global:
82138
return False
83139

84140
# Block multicast (not caught by is_global for some ranges)
85-
if ip.is_multicast:
86-
return False
87-
88-
# IPv6-specific checks for embedded IPv4 addresses
89-
if isinstance(ip, ipaddress.IPv6Address):
90-
if ip.ipv4_mapped:
91-
return is_ip_allowed(str(ip.ipv4_mapped))
92-
if ip.sixtofour:
93-
return is_ip_allowed(str(ip.sixtofour))
94-
if ip.teredo:
95-
server, client = ip.teredo
96-
return is_ip_allowed(str(server)) and is_ip_allowed(str(client))
97-
if ip in NAT64_WELL_KNOWN_PREFIX:
98-
return is_ip_allowed(str(ipaddress.IPv4Address(ip.packed[-4:])))
99-
100-
return True
141+
return not ip.is_multicast
101142

102143

103144
async def resolve_hostname(hostname: str, port: int = 443) -> list[str]:

tests/server/auth/test_ssrf_protection.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,44 @@ def test_ipv4_mapped_ipv6_blocked_if_private(self):
5454
@pytest.mark.parametrize(
5555
"address",
5656
[
57-
pytest.param("64:ff9b::7f00:1", id="loopback"),
58-
pytest.param("64:ff9b::0a00:1", id="private"),
59-
pytest.param("64:ff9b::a9fe:a9fe", id="link-local"),
60-
pytest.param("64:ff9b::6440:1", id="cgnat"),
57+
pytest.param("64:ff9b::7f00:1", id="nat64-loopback"),
58+
pytest.param("64:ff9b::0a00:1", id="nat64-private"),
59+
pytest.param("64:ff9b::a9fe:a9fe", id="nat64-link-local"),
60+
pytest.param("64:ff9b::6440:1", id="nat64-cgnat"),
61+
pytest.param("64:ff9b:1::a9fe:a9fe", id="nat64-local-use-low32"),
62+
pytest.param("64:ff9b:1:a9fe:a9:fe00::", id="nat64-local-use-48"),
63+
pytest.param("::ffff:0:7f00:1", id="ipv4-translated-loopback"),
64+
pytest.param("::ffff:0:0a00:1", id="ipv4-translated-private"),
65+
pytest.param("::ffff:0:a9fe:a9fe", id="ipv4-translated-link-local"),
66+
pytest.param("::ffff:0:6440:1", id="ipv4-translated-cgnat"),
67+
pytest.param("::7f00:1", id="ipv4-compatible-loopback"),
68+
pytest.param("::0a00:1", id="ipv4-compatible-private"),
69+
pytest.param("::a9fe:a9fe", id="ipv4-compatible-link-local"),
70+
pytest.param("::6440:1", id="ipv4-compatible-cgnat"),
71+
pytest.param("2002:a9fe:a9fe::1", id="6to4-link-local"),
72+
pytest.param("2606:4700::5efe:192.168.1.1", id="isatap-private"),
73+
pytest.param(
74+
"2606:4700::200:5efe:169.254.169.254",
75+
id="isatap-link-local",
76+
),
6177
],
6278
)
63-
def test_nat64_ipv6_blocked_if_embedded_ipv4_blocked(self, address: str):
64-
"""NAT64 IPv6 addresses should check the embedded IPv4."""
79+
def test_ipv6_transition_blocked_if_embedded_ipv4_blocked(self, address: str):
80+
"""IPv6 transition addresses should check the embedded IPv4."""
6581
assert is_ip_allowed(address) is False
6682

67-
def test_nat64_ipv6_allowed_if_embedded_ipv4_allowed(self):
68-
"""NAT64 IPv6 addresses should stay allowed for public embedded IPv4."""
69-
assert is_ip_allowed("64:ff9b::0808:0808") is True
83+
@pytest.mark.parametrize(
84+
"address",
85+
[
86+
pytest.param("64:ff9b::0808:0808", id="nat64"),
87+
pytest.param("::ffff:0:0808:0808", id="ipv4-translated"),
88+
pytest.param("::0808:0808", id="ipv4-compatible"),
89+
pytest.param("2606:4700::5efe:8.8.8.8", id="isatap"),
90+
],
91+
)
92+
def test_ipv6_transition_allowed_if_embedded_ipv4_allowed(self, address: str):
93+
"""IPv6 transition addresses should allow public embedded IPv4."""
94+
assert is_ip_allowed(address) is True
7095

7196

7297
class TestValidateURL:
@@ -100,11 +125,21 @@ async def test_private_ip_rejected(self):
100125
with pytest.raises(SSRFError, match="blocked IP"):
101126
await validate_url("https://example.com/path")
102127

103-
async def test_nat64_private_ip_rejected(self):
104-
"""URLs resolving to NAT64-wrapped private IPs should be rejected."""
128+
@pytest.mark.parametrize(
129+
"address",
130+
[
131+
pytest.param("64:ff9b::0a00:1", id="nat64"),
132+
pytest.param("64:ff9b:1:a9fe:a9:fe00::", id="nat64-local-use"),
133+
pytest.param("::ffff:0:a9fe:a9fe", id="ipv4-translated"),
134+
pytest.param("::a9fe:a9fe", id="ipv4-compatible"),
135+
pytest.param("2606:4700::5efe:169.254.169.254", id="isatap"),
136+
],
137+
)
138+
async def test_ipv6_transition_private_ip_rejected(self, address: str):
139+
"""URLs resolving to IPv6-wrapped private IPs should be rejected."""
105140
with patch(
106141
"fastmcp.server.auth.ssrf.resolve_hostname",
107-
return_value=["64:ff9b::0a00:1"],
142+
return_value=[address],
108143
):
109144
with pytest.raises(SSRFError, match="blocked IP"):
110145
await validate_url("https://example.com/path")

0 commit comments

Comments
 (0)