Block IPv6 addresses that reach internal IPs in the unfurl guard

The guard blocked the usual private, loopback, and link-local ranges (and
the IPv4-mapped/-compatible IPv6 forms), but let through NAT64, 6to4, and
Teredo addresses, which can point at an internal IPv4, and CGNAT.

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.
This commit is contained in:
Donal McBreen
2026-07-20 16:20:15 +01:00
parent df5ed25e14
commit 9fb419e469
2 changed files with 85 additions and 3 deletions
+50 -3
View File
@@ -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
@@ -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?("")