Files
once-campfire/test/lib/restricted_http/private_network_guard_test.rb
T
Jeremy Daer c860b51109 Take the SSRF address policy from surfguard instead of keeping our own copy (#241)
* Take the SSRF address policy from surfguard instead of keeping our own copy

Four other apps carried this same classification and the five had drifted into
four different ideas of what "internal" means. It now comes from the surfguard
gem, which is their union. resolve and Violation keep their shapes, so the
opengraph callers are unchanged.

Two verdicts change.

SIIT (::ffff:0:0:0/96) is now recognised. It is the third way an IPv4 address
rides inside an IPv6 one and the only one Ruby has no predicate for --
ipv4_mapped?, ipv4_compat?, private?, loopback? and link_local? are all false
for ::ffff:0:a9fe:a9fe, so it fell through to the IPv6 branch unrecognised and
reached the metadata endpoint. Note the extra group: ::ffff:0:0:0/96 is not the
IPv4-mapped ::ffff:0:0/96 the guard already refused, and the two do not overlap.

The RFC 8215 local-use NAT64 block is now refused whole rather than decoded.
Reading its low 32 bits as an embedded IPv4 is only correct for a /96 Pref64;
the block can host any length from /32 to /96 and the position is not
recoverable from the address alone (RFC 6052 2.2), so the decode reads the
wrong octets. It is never globally routed, so refusing it costs nothing. The
well-known /96 is still decoded and re-checked, so DNS64 for public sites on
IPv6-only hosts keeps working.

Resolution moves from Resolv.getaddress to Resolv.getaddresses, so the guard
sees every address a host answers with rather than only the first.

* Distinguish a DNS lookup failure from a private-IP block in the guard

Advance the surfguard pin so resolve_public_ips raises Unresolvable when a
host resolves to nothing and returns an empty list only when it resolves to a
blocked address. The shim lets Unresolvable propagate as a lookup failure --
matching the old Resolv.getaddress behavior -- and reserves Violation for a
resolved-but-blocked address, so a transient DNS miss is no longer reported as
an SSRF attempt.
2026-08-20 01:59:19 -07:00

156 lines
6.2 KiB
Ruby

require "test_helper"
require "restricted_http/private_network_guard"
class RestrictedHTTP::PrivateNetworkGuardTest < ActiveSupport::TestCase
test "private_ip? returns true for 'This' network (RFC1700)" do
assert_private_ip "0.0.0.0"
assert_private_ip "0.255.255.255"
end
test "private_ip? returns true for loopback addresses" do
assert_private_ip "127.0.0.0"
assert_private_ip "127.0.0.1"
assert_private_ip "127.255.255.255"
end
test "private_ip? returns true for RFC1918 private addresses" do
assert_private_ip "10.0.0.0"
assert_private_ip "10.255.255.255"
assert_private_ip "172.16.0.0"
assert_private_ip "172.31.255.255"
assert_private_ip "192.168.0.0"
assert_private_ip "192.168.255.255"
end
test "private_ip? returns true for link-local addresses" do
assert_private_ip "169.254.0.1"
assert_private_ip "169.254.169.254" # AWS IMDS
assert_private_ip "169.254.255.255"
end
test "private_ip? returns false for public addresses" do
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("93.184.216.34")
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("8.8.8.8")
end
# IPv6 address format tests (SSRF bypass prevention)
test "private_ip? returns true for IPv4-mapped IPv6 addresses with private IPs" do
assert_private_ip "::ffff:192.168.1.1"
assert_private_ip "::ffff:10.0.0.1"
assert_private_ip "::ffff:172.16.0.1"
end
test "private_ip? returns true for IPv4-mapped IPv6 addresses with link-local IPs" do
assert_private_ip "::ffff:169.254.169.254" # AWS metadata via mapped format
end
test "private_ip? returns true for IPv4-mapped IPv6 addresses even with public IPs" do
# Block all ipv4_mapped? since DNS never returns this format legitimately
assert_private_ip "::ffff:93.184.216.34"
end
test "private_ip? returns true for IPv4-compatible IPv6 addresses with private IPs" do
assert_private_ip "::192.168.1.1"
assert_private_ip "::10.0.0.1"
end
test "private_ip? returns true for IPv4-compatible IPv6 addresses with link-local IPs" do
assert_private_ip "::169.254.169.254" # AWS metadata via compat format - the reported bypass
end
test "private_ip? returns true for IPv4-compatible IPv6 addresses even with public IPs" do
# Block all ipv4_compat? since DNS never returns this format legitimately
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
# The local-use block is refused whole rather than decoded. A Pref64 inside it
# can be any length from /32 to /96 and the position is not recoverable from
# the address alone (RFC 6052 §2.2), so reading the low 32 bits reads the
# wrong octets -- 64:ff9b:1::808:808 only looks like 8.8.8.8 under a /96
# reading. The block is never globally routed, so nothing legitimate is lost.
test "private_ip? returns true for the whole local-use NAT64 block (RFC8215)" do
assert_private_ip "64:ff9b:1::a00:1" # reads as 10.0.0.1 under a /96
assert_private_ip "64:ff9b:1::808:808" # reads as 8.8.8.8 under a /96
assert_private_ip "64:ff9b:1:ffff::1"
end
# SIIT is the third way an IPv4 address rides inside an IPv6 one and the only
# one Ruby has no predicate for: ipv4_mapped?, ipv4_compat?, private?,
# loopback? and link_local? are all false here. Note the extra group --
# ::ffff:0:0:0/96 is not the IPv4-mapped ::ffff:0:0/96, and they do not
# overlap -- so this reached the metadata endpoint unrecognised.
test "private_ip? returns true for SIIT IPv4-translated addresses (RFC2765)" do
assert_private_ip "::ffff:0:169.254.169.254"
assert_private_ip "::ffff:0:a9fe:a9fe" # the same address, hex spelling
assert_private_ip "::ffff:0:127.0.0.1"
assert_private_ip "::ffff:0:192.168.0.1"
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?("")
end
test "resolve raises Violation for private hostname" do
Resolv.stubs(:getaddresses).returns([ "192.168.1.1" ])
assert_raises RestrictedHTTP::Violation do
RestrictedHTTP::PrivateNetworkGuard.resolve("private.example.com")
end
end
test "resolve returns IP for public hostname" do
Resolv.stubs(:getaddresses).returns([ "93.184.216.34" ])
assert_equal "93.184.216.34", RestrictedHTTP::PrivateNetworkGuard.resolve("example.com")
end
test "resolve raises Unresolvable, not Violation, when the host resolves to nothing" do
Resolv.stubs(:getaddresses).returns([])
assert_raises Surfguard::Unresolvable do
RestrictedHTTP::PrivateNetworkGuard.resolve("nxdomain.example.com")
end
end
private
def assert_private_ip(address)
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?(address),
"Expected #{address} to be classified as private"
end
end