diff --git a/app/models/ssrf_protection.rb b/app/models/ssrf_protection.rb index 40be7d157..b4390a9ba 100644 --- a/app/models/ssrf_protection.rb +++ b/app/models/ssrf_protection.rb @@ -8,10 +8,49 @@ 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: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 + + # 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) @@ -21,14 +60,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_PREFIXES.any? { |prefix| prefix.include?(ipaddr) } + disallowed_ipv4?(embedded_ipv4(ipaddr)) + else + disallowed_ipv6?(ipaddr) + end + rescue IPAddr::InvalidAddressError + true end private @@ -44,7 +90,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 fc2828b60..7504de26a 100644 --- a/test/models/ssrf_protection_test.rb +++ b/test/models/ssrf_protection_test.rb @@ -82,4 +82,59 @@ 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 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") + + stub_dns_resolution("2001::1") # Teredo + 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") + + 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