mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-08-11 01:28:44 +09:00
Merge pull request #225 from basecamp/security/ssrf-ipv6-transition-guard
Block IPv6 addresses that reach internal IPs in the unfurl guard
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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?("")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user