diff --git a/lib/restricted_http/private_network_guard.rb b/lib/restricted_http/private_network_guard.rb index 193deeb..b9ea957 100644 --- a/lib/restricted_http/private_network_guard.rb +++ b/lib/restricted_http/private_network_guard.rb @@ -6,7 +6,29 @@ module RestrictedHTTP module PrivateNetworkGuard extend self - LOCAL_IP = IPAddr.new("0.0.0.0/8") # "This" network + # 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 #disallowed_ipv4?. + DISALLOWED_IPV4 = %w[ + 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 + 172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24 192.168.0.0/16 + 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4 + ].map { |cidr| IPAddr.new(cidr) }.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. the AWS + # IMDSv6 address fd00:ec2::254), link-local, and loopback are covered by the + # predicates in #disallowed_ipv6?. + DISALLOWED_IPV6 = %w[ + ::/128 100::/64 2001::/32 2001:db8::/32 2002::/16 fec0::/10 ff00::/8 + ].map { |cidr| IPAddr.new(cidr) }.freeze + + # Well-known NAT64 prefix (RFC 6052/6146). An address here embeds an IPv4 + # target in its low 32 bits; we 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(hostname) Resolv.getaddress(hostname).tap do |ip| @@ -15,11 +37,36 @@ module RestrictedHTTP end def private_ip?(ip) - IPAddr.new(ip).then do |ipaddr| - ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || ipaddr.ipv4_compat? || LOCAL_IP.include?(ipaddr) + ipaddr = IPAddr.new(ip) + + # 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 + def disallowed_ipv4?(ipaddr) + ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || + DISALLOWED_IPV4.any? { |range| range.include?(ipaddr) } + end + + def disallowed_ipv6?(ipaddr) + ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || + DISALLOWED_IPV6.any? { |range| range.include?(ipaddr) } + end + + def embedded_ipv4(ipaddr) + IPAddr.new([ ipaddr.to_i & 0xffffffff ].pack("N").unpack("C4").join(".")) + end end end diff --git a/test/lib/restricted_http/private_network_guard_test.rb b/test/lib/restricted_http/private_network_guard_test.rb index 816a648..f70500b 100644 --- a/test/lib/restricted_http/private_network_guard_test.rb +++ b/test/lib/restricted_http/private_network_guard_test.rb @@ -64,6 +64,41 @@ class RestrictedHTTP::PrivateNetworkGuardTest < ActiveSupport::TestCase assert_private_ip "::93.184.216.34" end + test "private_ip? returns true for carrier-grade NAT addresses (RFC6598)" do + assert_private_ip "100.64.0.1" + assert_private_ip "100.127.255.255" + end + + test "private_ip? returns true for NAT64 addresses embedding a private IPv4" do + assert_private_ip "64:ff9b::a9fe:a9fe" # NAT64 -> 169.254.169.254 (AWS metadata) + assert_private_ip "64:ff9b::a00:5" # NAT64 -> 10.0.0.5 + end + + test "private_ip? returns false for NAT64 addresses embedding a public IPv4" do + # DNS64 legitimately synthesizes these for public sites on IPv6-only hosts. + assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("64:ff9b::808:808") # -> 8.8.8.8 + end + + test "private_ip? returns true for 6to4 and Teredo transition addresses" do + assert_private_ip "2002:a9fe:a9fe::" # 6to4 embedding 169.254.169.254 + assert_private_ip "2001::1" # Teredo + end + + test "private_ip? returns true for IPv6 loopback, ULA, and link-local" do + assert_private_ip "::1" + assert_private_ip "fd00:ec2::254" # AWS IMDSv6 (ULA) + assert_private_ip "fe80::1" + end + + test "private_ip? returns true for IPv6 multicast and documentation ranges" do + assert_private_ip "ff02::1" + assert_private_ip "2001:db8::1" + end + + test "private_ip? returns false for public IPv6 addresses" do + assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("2606:4700:4700::1111") + end + test "private_ip? returns true for invalid addresses" do assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("not-an-ip") assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("")