From 9fb419e4692961f0d4d234fb00773e52c0d11f39 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 Jul 2026 16:20:15 +0100 Subject: [PATCH 1/6] 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. --- lib/restricted_http/private_network_guard.rb | 53 +++++++++++++++++-- .../private_network_guard_test.rb | 35 ++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) 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?("") From 0de302c977496c4ccdc77c5ceeb21979791d409f Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 Jul 2026 16:20:15 +0100 Subject: [PATCH 2/6] Fix unfurl rebinding tests for newer Ruby Net::HTTP now passes an open_timeout: option to TCPSocket.open, so the mock that matched exact positional arguments no longer matches. Match on the host instead. --- test/models/opengraph/fetch_test.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/models/opengraph/fetch_test.rb b/test/models/opengraph/fetch_test.rb index 3bcf19d..e0bf2b0 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" }.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" }.throws(:dns_not_rebound) assert_throws :dns_not_rebound do @fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4") From 9085adcbb38e3b9ef066f6a8b1794546b518e19e Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 Jul 2026 16:49:40 +0100 Subject: [PATCH 3/6] Block local-use NAT64 and IPv6 benchmarking ranges The local-use NAT64 prefix (64:ff9b:1::/48, RFC 8215) embeds an IPv4 target like the well-known prefix does, but at a deployment-chosen position we can't extract, so block the whole range outright. Also block the IPv6 benchmarking range (2001:2::/48, RFC 5180) to match the IPv4 benchmarking block on 198.18.0.0/15. --- lib/restricted_http/private_network_guard.rb | 7 ++++++- test/lib/restricted_http/private_network_guard_test.rb | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/restricted_http/private_network_guard.rb b/lib/restricted_http/private_network_guard.rb index b9ea957..4226e35 100644 --- a/lib/restricted_http/private_network_guard.rb +++ b/lib/restricted_http/private_network_guard.rb @@ -20,8 +20,13 @@ module RestrictedHTTP # 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?. + # The local-use NAT64 prefix (64:ff9b:1::/48, RFC 8215) also embeds an + # IPv4 target, but at a deployment-chosen position we can't extract, and it + # is only meaningful inside the network that deployed it, so it is blocked + # outright rather than handled like NAT64_WELL_KNOWN below. DISALLOWED_IPV6 = %w[ - ::/128 100::/64 2001::/32 2001:db8::/32 2002::/16 fec0::/10 ff00::/8 + ::/128 64:ff9b:1::/48 100::/64 2001::/32 2001:2::/48 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 diff --git a/test/lib/restricted_http/private_network_guard_test.rb b/test/lib/restricted_http/private_network_guard_test.rb index f70500b..81f11e3 100644 --- a/test/lib/restricted_http/private_network_guard_test.rb +++ b/test/lib/restricted_http/private_network_guard_test.rb @@ -74,6 +74,11 @@ class RestrictedHTTP::PrivateNetworkGuardTest < ActiveSupport::TestCase assert_private_ip "64:ff9b::a00:5" # NAT64 -> 10.0.0.5 end + test "private_ip? returns true for local-use NAT64 addresses (RFC8215)" do + assert_private_ip "64:ff9b:1:fffe::a00:1" # local-use NAT64 embedding 10.0.0.1 + assert_private_ip "64:ff9b:1::808:808" # blocked even when embedding a public IP + 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 @@ -90,9 +95,10 @@ class RestrictedHTTP::PrivateNetworkGuardTest < ActiveSupport::TestCase assert_private_ip "fe80::1" end - test "private_ip? returns true for IPv6 multicast and documentation ranges" do + 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 From 4cfcc2a370447b5a638bd329d8ed149ee1d7c3bf Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 20 Jul 2026 17:13:18 +0100 Subject: [PATCH 4/6] Re-check the IPv4 embedded in local-use NAT64 instead of blocking outright Matches the fizzy guard: 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 now resolves (keeping unfurls working for self-hosters on such networks) while local-use NAT64 to an internal address stays blocked. --- lib/restricted_http/private_network_guard.rb | 24 +++++++++---------- .../private_network_guard_test.rb | 9 ++++--- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/lib/restricted_http/private_network_guard.rb b/lib/restricted_http/private_network_guard.rb index 4226e35..193264f 100644 --- a/lib/restricted_http/private_network_guard.rb +++ b/lib/restricted_http/private_network_guard.rb @@ -20,20 +20,20 @@ module RestrictedHTTP # 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?. - # The local-use NAT64 prefix (64:ff9b:1::/48, RFC 8215) also embeds an - # IPv4 target, but at a deployment-chosen position we can't extract, and it - # is only meaningful inside the network that deployed it, so it is blocked - # outright rather than handled like NAT64_WELL_KNOWN below. DISALLOWED_IPV6 = %w[ - ::/128 64:ff9b:1::/48 100::/64 2001::/32 2001:2::/48 2001:db8::/32 - 2002::/16 fec0::/10 ff00::/8 + ::/128 100::/64 2001::/32 2001:2::/48 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") + # 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| @@ -50,7 +50,7 @@ module RestrictedHTTP 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/lib/restricted_http/private_network_guard_test.rb b/test/lib/restricted_http/private_network_guard_test.rb index 81f11e3..336bf9d 100644 --- a/test/lib/restricted_http/private_network_guard_test.rb +++ b/test/lib/restricted_http/private_network_guard_test.rb @@ -74,9 +74,12 @@ class RestrictedHTTP::PrivateNetworkGuardTest < ActiveSupport::TestCase assert_private_ip "64:ff9b::a00:5" # NAT64 -> 10.0.0.5 end - test "private_ip? returns true for local-use NAT64 addresses (RFC8215)" do - assert_private_ip "64:ff9b:1:fffe::a00:1" # local-use NAT64 embedding 10.0.0.1 - assert_private_ip "64:ff9b:1::808:808" # blocked even when embedding a public IP + 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 From 80fdd446220c734e6efd0e51c82e6552ae868308 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 21 Jul 2026 11:37:17 +0100 Subject: [PATCH 5/6] Remove IPv4 ranges the IPAddr predicates already cover The RFC1918, loopback, and link-local ranges in DISALLOWED_IPV4 duplicated the private?/loopback?/link_local? checks that run right before the list scan, so they could never be the deciding factor. Keep only the ranges the predicates don't catch. --- lib/restricted_http/private_network_guard.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/restricted_http/private_network_guard.rb b/lib/restricted_http/private_network_guard.rb index 193264f..5dd8f98 100644 --- a/lib/restricted_http/private_network_guard.rb +++ b/lib/restricted_http/private_network_guard.rb @@ -6,12 +6,10 @@ module RestrictedHTTP module PrivateNetworkGuard extend self - # 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?. + # 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 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 + 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 From c0cade51a1f252db89fd9e8dfbed7dddf2dfd018 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 21 Jul 2026 11:53:37 +0100 Subject: [PATCH 6/6] Address Copilot review: require ipaddr and re-assert the socket port The guard uses IPAddr but relied on something else loading it first; require it explicitly. And the rebinding tests lost their port assertion when the matchers were loosened for newer Net::HTTP keyword args, so check the port alongside the IP again. --- lib/restricted_http/private_network_guard.rb | 1 + test/models/opengraph/fetch_test.rb | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/restricted_http/private_network_guard.rb b/lib/restricted_http/private_network_guard.rb index 5dd8f98..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 diff --git a/test/models/opengraph/fetch_test.rb b/test/models/opengraph/fetch_test.rb index e0bf2b0..c110a44 100644 --- a/test/models/opengraph/fetch_test.rb +++ b/test/models/opengraph/fetch_test.rb @@ -50,7 +50,7 @@ class Opengraph::FetchTest < ActiveSupport::TestCase 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 { |*args, **| args.first == @url.host }.never - TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" }.throws(:dns_not_rebound) + 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) @@ -67,7 +67,7 @@ class Opengraph::FetchTest < ActiveSupport::TestCase 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 { |*args, **| args.first == @url.host }.never - TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" }.throws(:dns_not_rebound) + 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")