From db098dbb08a41970270beda81fa712ad1e99f6a5 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 Jul 2026 16:13:56 +0100 Subject: [PATCH 1/2] Block IPv6 addresses that reach internal IPs in the SSRF guard The guard blocked the usual private, loopback, and link-local ranges (and the IPv4-mapped/-compatible IPv6 forms) and CGNAT, but let through NAT64, 6to4, and Teredo addresses, which can point at an internal IPv4. Now it pulls the IPv4 out of a NAT64 address and checks that (so NAT64 to a public site still works), blocks 6to4 and Teredo outright, and adds the missing IPv4 and IPv6 ranges. --- app/models/ssrf_protection.rb | 78 ++++++++++++++++++++++++----- test/models/ssrf_protection_test.rb | 40 +++++++++++++++ 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/app/models/ssrf_protection.rb b/app/models/ssrf_protection.rb index 40be7d1573..2c6a570f9c 100644 --- a/app/models/ssrf_protection.rb +++ b/app/models/ssrf_protection.rb @@ -8,12 +8,47 @@ module SsrfProtection 8.8.8.8 ] + # IPv4 ranges that must never be a fetch target (RFC 5735/6890 special-use, + # plus CGNAT and benchmarking). RFC1918/loopback/link-local are also covered + # by the IPAddr predicates in #blocked_address?. DISALLOWED_IP_RANGES = [ - IPAddr.new("0.0.0.0/8"), # "This" network (RFC1700) - IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC6598) - IPAddr.new("198.18.0.0/15") # Benchmark testing (RFC2544) + IPAddr.new("0.0.0.0/8"), # "This" network (RFC1700) + IPAddr.new("10.0.0.0/8"), # Private (RFC1918) + IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC6598) + IPAddr.new("127.0.0.0/8"), # Loopback + IPAddr.new("169.254.0.0/16"), # Link-local (incl. AWS metadata) + IPAddr.new("172.16.0.0/12"), # Private (RFC1918) + IPAddr.new("192.0.0.0/24"), # IETF protocol assignments (RFC6890) + IPAddr.new("192.0.2.0/24"), # TEST-NET-1 (RFC5737) + IPAddr.new("192.88.99.0/24"), # 6to4 relay anycast (RFC7526) + IPAddr.new("192.168.0.0/16"), # Private (RFC1918) + IPAddr.new("198.18.0.0/15"), # Benchmark testing (RFC2544) + IPAddr.new("198.51.100.0/24"), # TEST-NET-2 (RFC5737) + IPAddr.new("203.0.113.0/24"), # TEST-NET-3 (RFC5737) + IPAddr.new("224.0.0.0/4"), # Multicast (RFC5771) + IPAddr.new("240.0.0.0/4") # Reserved / future use (RFC1112) ].freeze + # IPv6 special-use ranges not caught by the predicates. 6to4 (2002::/16) and + # Teredo (2001::/32) are deprecated transition mechanisms with no legitimate + # fetch target, so they are blocked outright. ULA (fc00::/7, incl. AWS + # IMDSv6 fd00:ec2::254), link-local, and loopback are covered by the predicates. + DISALLOWED_IPV6_RANGES = [ + IPAddr.new("::/128"), # Unspecified + IPAddr.new("100::/64"), # Discard-only (RFC6666) + IPAddr.new("2001::/32"), # Teredo (RFC4380) + IPAddr.new("2001:db8::/32"), # Documentation (RFC3849) + IPAddr.new("2002::/16"), # 6to4 (RFC3056) + IPAddr.new("fec0::/10"), # Deprecated site-local (RFC3879) + IPAddr.new("ff00::/8") # Multicast (RFC4291) + ].freeze + + # Well-known NAT64 prefix (RFC 6052/6146). An address here embeds an IPv4 + # target in its low 32 bits; extract it and re-check against the IPv4 rules so + # NAT64 to a public address still resolves while NAT64 to an internal address + # is blocked. + NAT64_WELL_KNOWN = IPAddr.new("64:ff9b::/96") + def resolve_public_ip(hostname) ip_addresses = resolve_dns(hostname) public_ips = ip_addresses.reject { |ip| blocked_address?(ip) } @@ -21,14 +56,21 @@ def resolve_public_ip(hostname) end def blocked_address?(ip) - ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr) - - ip.private? || - ip.loopback? || - ip.link_local? || - ip.ipv4_mapped? || - ip.ipv4_compat? || - in_disallowed_range?(ip) + ipaddr = ip.is_a?(IPAddr) ? ip : IPAddr.new(ip.to_s) + + # DNS never legitimately returns these embedded forms, so block them all + # regardless of the address they wrap. + if ipaddr.ipv4_mapped? || ipaddr.ipv4_compat? + true + elsif ipaddr.ipv4? + disallowed_ipv4?(ipaddr) + elsif NAT64_WELL_KNOWN.include?(ipaddr) + disallowed_ipv4?(embedded_ipv4(ipaddr)) + else + disallowed_ipv6?(ipaddr) + end + rescue IPAddr::InvalidAddressError + true end private @@ -44,7 +86,17 @@ def resolve_dns(hostname) ip_addresses end - def in_disallowed_range?(ip) - DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) } + def disallowed_ipv4?(ipaddr) + ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || + DISALLOWED_IP_RANGES.any? { |range| range.include?(ipaddr) } + end + + def disallowed_ipv6?(ipaddr) + ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || + DISALLOWED_IPV6_RANGES.any? { |range| range.include?(ipaddr) } + end + + def embedded_ipv4(ipaddr) + IPAddr.new([ ipaddr.to_i & 0xffffffff ].pack("N").unpack("C4").join(".")) end end diff --git a/test/models/ssrf_protection_test.rb b/test/models/ssrf_protection_test.rb index fc2828b60f..be6e5a5b14 100644 --- a/test/models/ssrf_protection_test.rb +++ b/test/models/ssrf_protection_test.rb @@ -82,4 +82,44 @@ class SsrfProtectionTest < ActiveSupport::TestCase stub_dns_resolution("::93.184.216.34") assert_nil SsrfProtection.resolve_public_ip("compat-public.example.com") end + + test "blocks NAT64 addresses embedding a private IPv4" do + stub_dns_resolution("64:ff9b::a9fe:a9fe") # NAT64 -> 169.254.169.254 (AWS metadata) + assert_nil SsrfProtection.resolve_public_ip("nat64-metadata.example.com") + end + + test "blocks NAT64 addresses embedding a carrier-grade NAT IPv4" do + stub_dns_resolution("64:ff9b::6440:1") # NAT64 -> 100.64.0.1 + assert_nil SsrfProtection.resolve_public_ip("nat64-cgnat.example.com") + end + + test "allows NAT64 addresses embedding a public IPv4" do + # DNS64 legitimately synthesizes these for public sites on IPv6-only hosts. + stub_dns_resolution("64:ff9b::808:808") # NAT64 -> 8.8.8.8 + assert_not_nil SsrfProtection.resolve_public_ip("nat64-public.example.com") + end + + test "blocks 6to4 and Teredo transition addresses" do + stub_dns_resolution("2002:a9fe:a9fe::") # 6to4 embedding 169.254.169.254 + assert_nil SsrfProtection.resolve_public_ip("sixtofour.example.com") + + stub_dns_resolution("2001::1") # Teredo + assert_nil SsrfProtection.resolve_public_ip("teredo.example.com") + end + + test "blocks IPv6 loopback, ULA, and multicast" do + stub_dns_resolution("::1") + assert_nil SsrfProtection.resolve_public_ip("v6-loopback.example.com") + + stub_dns_resolution("fd00:ec2::254") # AWS IMDSv6 (ULA) + assert_nil SsrfProtection.resolve_public_ip("v6-imds.example.com") + + stub_dns_resolution("ff02::1") + assert_nil SsrfProtection.resolve_public_ip("v6-multicast.example.com") + end + + test "allows public IPv6 addresses" do + stub_dns_resolution("2606:4700:4700::1111") + assert_not_nil SsrfProtection.resolve_public_ip("v6-public.example.com") + end end From 95b2d43edf2200909ca41c5043a8e267f9571e61 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 Jul 2026 17:08:05 +0100 Subject: [PATCH 2/2] Block local-use NAT64 and IPv6 benchmarking ranges The local-use NAT64 prefix (64:ff9b:1::/48, RFC 8215) embeds an IPv4 target in its low 32 bits just like the well-known prefix, so run it through the same embedded-IPv4 recheck: local-use NAT64 to a public address still resolves while local-use NAT64 to an internal address is blocked. Also block the IPv6 benchmarking range (2001:2::/48, RFC 5180) to match the IPv4 benchmarking block on 198.18.0.0/15. --- app/models/ssrf_protection.rb | 16 ++++++++++------ test/models/ssrf_protection_test.rb | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/models/ssrf_protection.rb b/app/models/ssrf_protection.rb index 2c6a570f9c..b4390a9ba8 100644 --- a/app/models/ssrf_protection.rb +++ b/app/models/ssrf_protection.rb @@ -37,17 +37,21 @@ module SsrfProtection IPAddr.new("::/128"), # Unspecified IPAddr.new("100::/64"), # Discard-only (RFC6666) IPAddr.new("2001::/32"), # Teredo (RFC4380) + IPAddr.new("2001:2::/48"), # Benchmark testing (RFC5180) IPAddr.new("2001:db8::/32"), # Documentation (RFC3849) IPAddr.new("2002::/16"), # 6to4 (RFC3056) IPAddr.new("fec0::/10"), # Deprecated site-local (RFC3879) IPAddr.new("ff00::/8") # Multicast (RFC4291) ].freeze - # Well-known NAT64 prefix (RFC 6052/6146). An address here embeds an IPv4 - # target in its low 32 bits; extract it and re-check against the IPv4 rules so - # NAT64 to a public address still resolves while NAT64 to an internal address - # is blocked. - NAT64_WELL_KNOWN = IPAddr.new("64:ff9b::/96") + # NAT64 prefixes: the well-known prefix (RFC 6052/6146) and the local-use + # prefix (RFC 8215). An address here embeds an IPv4 target in its low 32 + # bits; extract it and re-check against the IPv4 rules so NAT64 to a public + # address still resolves while NAT64 to an internal address is blocked. + NAT64_PREFIXES = [ + IPAddr.new("64:ff9b::/96"), + IPAddr.new("64:ff9b:1::/48") + ].freeze def resolve_public_ip(hostname) ip_addresses = resolve_dns(hostname) @@ -64,7 +68,7 @@ def blocked_address?(ip) true elsif ipaddr.ipv4? disallowed_ipv4?(ipaddr) - elsif NAT64_WELL_KNOWN.include?(ipaddr) + elsif NAT64_PREFIXES.any? { |prefix| prefix.include?(ipaddr) } disallowed_ipv4?(embedded_ipv4(ipaddr)) else disallowed_ipv6?(ipaddr) diff --git a/test/models/ssrf_protection_test.rb b/test/models/ssrf_protection_test.rb index be6e5a5b14..7504de26a4 100644 --- a/test/models/ssrf_protection_test.rb +++ b/test/models/ssrf_protection_test.rb @@ -99,6 +99,16 @@ class SsrfProtectionTest < ActiveSupport::TestCase assert_not_nil SsrfProtection.resolve_public_ip("nat64-public.example.com") end + test "blocks local-use NAT64 addresses embedding a private IPv4 (RFC8215)" do + stub_dns_resolution("64:ff9b:1::a00:1") # local-use NAT64 -> 10.0.0.1 + assert_nil SsrfProtection.resolve_public_ip("nat64-local.example.com") + end + + test "allows local-use NAT64 addresses embedding a public IPv4 (RFC8215)" do + stub_dns_resolution("64:ff9b:1::808:808") # local-use NAT64 -> 8.8.8.8 + assert_not_nil SsrfProtection.resolve_public_ip("nat64-local-public.example.com") + end + test "blocks 6to4 and Teredo transition addresses" do stub_dns_resolution("2002:a9fe:a9fe::") # 6to4 embedding 169.254.169.254 assert_nil SsrfProtection.resolve_public_ip("sixtofour.example.com") @@ -107,6 +117,11 @@ class SsrfProtectionTest < ActiveSupport::TestCase assert_nil SsrfProtection.resolve_public_ip("teredo.example.com") end + test "blocks IPv6 benchmarking addresses (RFC5180)" do + stub_dns_resolution("2001:2::1") + assert_nil SsrfProtection.resolve_public_ip("v6-benchmark.example.com") + end + test "blocks IPv6 loopback, ULA, and multicast" do stub_dns_resolution("::1") assert_nil SsrfProtection.resolve_public_ip("v6-loopback.example.com")