From e983e3f79faa3fc99b1650551600795400c58a17 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 31 Dec 2025 13:01:43 -0800 Subject: [PATCH] Block IPv6 SSRF bypass via ipv4_compat addresses (#153) Adds ipv4_mapped? and ipv4_compat? checks to PrivateNetworkGuard.private_ip? to block SSRF bypass attempts using IPv6 address formats like: - ::ffff:169.254.169.254 (IPv4-mapped) - ::169.254.169.254 (IPv4-compatible) These formats could previously bypass the link_local? check since Ruby treats them as IPv6 addresses, not IPv4. Ref: HackerOne #3481701 --- lib/restricted_http/private_network_guard.rb | 2 +- .../private_network_guard_test.rb | 89 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 test/lib/restricted_http/private_network_guard_test.rb diff --git a/lib/restricted_http/private_network_guard.rb b/lib/restricted_http/private_network_guard.rb index 0747862..193deeb 100644 --- a/lib/restricted_http/private_network_guard.rb +++ b/lib/restricted_http/private_network_guard.rb @@ -16,7 +16,7 @@ module RestrictedHTTP def private_ip?(ip) IPAddr.new(ip).then do |ipaddr| - ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || LOCAL_IP.include?(ipaddr) + ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || ipaddr.ipv4_compat? || LOCAL_IP.include?(ipaddr) end rescue IPAddr::InvalidAddressError true diff --git a/test/lib/restricted_http/private_network_guard_test.rb b/test/lib/restricted_http/private_network_guard_test.rb new file mode 100644 index 0000000..816a648 --- /dev/null +++ b/test/lib/restricted_http/private_network_guard_test.rb @@ -0,0 +1,89 @@ +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 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(:getaddress).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(:getaddress).returns("93.184.216.34") + assert_equal "93.184.216.34", RestrictedHTTP::PrivateNetworkGuard.resolve("example.com") + end + + private + def assert_private_ip(address) + assert RestrictedHTTP::PrivateNetworkGuard.private_ip?(address), + "Expected #{address} to be classified as private" + end +end