From 5de15e0c215a8d992b2e9fdf4aff2e104873fb10 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:21:37 -0400 Subject: [PATCH] Block NAT64 SSRF bypass (#4400) --- fastmcp_slim/fastmcp/server/auth/ssrf.py | 5 +++++ tests/server/auth/test_ssrf_protection.py | 26 +++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/fastmcp_slim/fastmcp/server/auth/ssrf.py b/fastmcp_slim/fastmcp/server/auth/ssrf.py index 86ea2031d..ae0bea3cc 100644 --- a/fastmcp_slim/fastmcp/server/auth/ssrf.py +++ b/fastmcp_slim/fastmcp/server/auth/ssrf.py @@ -22,6 +22,8 @@ from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) +NAT64_WELL_KNOWN_PREFIX = ipaddress.ip_network("64:ff9b::/96") + def format_ip_for_url(ip_str: str) -> str: """Format IP address for use in URL (bracket IPv6 addresses). @@ -61,6 +63,7 @@ def is_ip_allowed(ip_str: str) -> bool: - Link-local (169.254.x, fe80::) - includes AWS metadata! - Reserved, unspecified - RFC6598 Carrier-Grade NAT (100.64.0.0/10) - can point to internal networks + - NAT64 (64:ff9b::/96) - can point to internal networks Additionally blocks multicast addresses (not caught by is_global). @@ -91,6 +94,8 @@ def is_ip_allowed(ip_str: str) -> bool: if ip.teredo: server, client = ip.teredo return is_ip_allowed(str(server)) and is_ip_allowed(str(client)) + if ip in NAT64_WELL_KNOWN_PREFIX: + return is_ip_allowed(str(ipaddress.IPv4Address(ip.packed[-4:]))) return True diff --git a/tests/server/auth/test_ssrf_protection.py b/tests/server/auth/test_ssrf_protection.py index 79cf926a0..4c03b10fe 100644 --- a/tests/server/auth/test_ssrf_protection.py +++ b/tests/server/auth/test_ssrf_protection.py @@ -51,6 +51,23 @@ class TestIsIPAllowed: assert is_ip_allowed("::ffff:127.0.0.1") is False assert is_ip_allowed("::ffff:192.168.1.1") is False + @pytest.mark.parametrize( + "address", + [ + pytest.param("64:ff9b::7f00:1", id="loopback"), + pytest.param("64:ff9b::0a00:1", id="private"), + pytest.param("64:ff9b::a9fe:a9fe", id="link-local"), + pytest.param("64:ff9b::6440:1", id="cgnat"), + ], + ) + def test_nat64_ipv6_blocked_if_embedded_ipv4_blocked(self, address: str): + """NAT64 IPv6 addresses should check the embedded IPv4.""" + assert is_ip_allowed(address) is False + + def test_nat64_ipv6_allowed_if_embedded_ipv4_allowed(self): + """NAT64 IPv6 addresses should stay allowed for public embedded IPv4.""" + assert is_ip_allowed("64:ff9b::0808:0808") is True + class TestValidateURL: """Tests for validate_url function.""" @@ -83,6 +100,15 @@ class TestValidateURL: with pytest.raises(SSRFError, match="blocked IP"): await validate_url("https://example.com/path") + async def test_nat64_private_ip_rejected(self): + """URLs resolving to NAT64-wrapped private IPs should be rejected.""" + with patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=["64:ff9b::0a00:1"], + ): + with pytest.raises(SSRFError, match="blocked IP"): + await validate_url("https://example.com/path") + class TestSSRFSafeFetch: """Tests for ssrf_safe_fetch function."""