diff --git a/lib/restricted_http/private_network_guard.rb b/lib/restricted_http/private_network_guard.rb index 193deeb..11901ba 100644 --- a/lib/restricted_http/private_network_guard.rb +++ b/lib/restricted_http/private_network_guard.rb @@ -1,3 +1,4 @@ +require "ipaddr" require "resolv" module RestrictedHTTP @@ -6,7 +7,32 @@ module RestrictedHTTP module PrivateNetworkGuard extend self - LOCAL_IP = IPAddr.new("0.0.0.0/8") # "This" network + # IPv4 special-use ranges (RFC 5735/6890) not already covered by the + # private?/loopback?/link_local? predicates in #disallowed_ipv4?. + DISALLOWED_IPV4 = %w[ + 0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24 + 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:2::/48 2001:db8::/32 2002::/16 + fec0::/10 ff00::/8 + ].map { |cidr| IPAddr.new(cidr) }.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; 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_PREFIXES = [ + IPAddr.new("64:ff9b::/96"), + IPAddr.new("64:ff9b:1::/48") + ].freeze def resolve(hostname) Resolv.getaddress(hostname).tap do |ip| @@ -15,11 +41,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_PREFIXES.any? { |prefix| prefix.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..336bf9d 100644 --- a/test/lib/restricted_http/private_network_guard_test.rb +++ b/test/lib/restricted_http/private_network_guard_test.rb @@ -64,6 +64,50 @@ 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 true for local-use NAT64 addresses embedding a private IPv4 (RFC8215)" do + assert_private_ip "64:ff9b:1::a00:1" # local-use NAT64 -> 10.0.0.1 + end + + test "private_ip? returns false for local-use NAT64 addresses embedding a public IPv4 (RFC8215)" do + assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("64:ff9b:1::808:808") # -> 8.8.8.8 + 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, documentation, and benchmarking ranges" do + assert_private_ip "ff02::1" + assert_private_ip "2001:db8::1" + assert_private_ip "2001:2::1" # benchmarking (RFC5180), matches 198.18.0.0/15 + 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?("") diff --git a/test/models/opengraph/fetch_test.rb b/test/models/opengraph/fetch_test.rb index 3bcf19d..c110a44 100644 --- a/test/models/opengraph/fetch_test.rb +++ b/test/models/opengraph/fetch_test.rb @@ -49,8 +49,8 @@ class Opengraph::FetchTest < ActiveSupport::TestCase # to a resolved IP, not a hostname to re-resolve. WebMock.disable_net_connect! allow: [ @url.host ] Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1") - TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never - TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound) + TCPSocket.expects(:open).with { |*args, **| args.first == @url.host }.never + TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound) assert_throws :dns_not_rebound do @fetch.fetch_document(@url) @@ -66,8 +66,8 @@ class Opengraph::FetchTest < ActiveSupport::TestCase # to a resolved IP, not a hostname to re-resolve. WebMock.disable_net_connect! allow: [ @url.host ] Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1") - TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never - TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound) + TCPSocket.expects(:open).with { |*args, **| args.first == @url.host }.never + TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound) assert_throws :dns_not_rebound do @fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4")