From 0eefd67b685dfe63d467916ae8aff935752eb953 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 29 Dec 2025 21:45:06 -0800 Subject: [PATCH] Block IPv4-compatible IPv6 addresses in SSRF protection (#2273) The SSRF filter checked ipv4_mapped? but not ipv4_compat?, allowing addresses like ::169.254.169.254 to bypass the link-local check and reach cloud metadata endpoints. Changes: - Add ipv4_compat? check to block deprecated IPv4-compatible format - Rename private_address? to blocked_address? (more accurate - method blocks more than just RFC 1918 private ranges) - Add IPv6 test coverage for both mapped and compat formats Both ipv4_mapped and ipv4_compat formats are blocked entirely as defense-in-depth: DNS never returns these formats, so they only appear in attack scenarios. HackerOne: #3481701 --- app/models/ssrf_protection.rb | 12 ++++++++--- test/models/ssrf_protection_test.rb | 32 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/models/ssrf_protection.rb b/app/models/ssrf_protection.rb index a73b6b3be..40be7d157 100644 --- a/app/models/ssrf_protection.rb +++ b/app/models/ssrf_protection.rb @@ -16,13 +16,19 @@ module SsrfProtection def resolve_public_ip(hostname) ip_addresses = resolve_dns(hostname) - public_ips = ip_addresses.reject { |ip| private_address?(ip) } + public_ips = ip_addresses.reject { |ip| blocked_address?(ip) } public_ips.sort_by { |ipaddr| ipaddr.ipv4? ? 0 : 1 }.first&.to_s end - def private_address?(ip) + def blocked_address?(ip) ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr) - ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || in_disallowed_range?(ip) + + ip.private? || + ip.loopback? || + ip.link_local? || + ip.ipv4_mapped? || + ip.ipv4_compat? || + in_disallowed_range?(ip) end private diff --git a/test/models/ssrf_protection_test.rb b/test/models/ssrf_protection_test.rb index d56f7390a..550909f89 100644 --- a/test/models/ssrf_protection_test.rb +++ b/test/models/ssrf_protection_test.rb @@ -51,6 +51,38 @@ class SsrfProtectionTest < ActiveSupport::TestCase assert_equal "93.184.216.34", SsrfProtection.resolve_public_ip("multi.example.com") end + # IPv6 address format tests (SSRF bypass prevention) + + test "blocks IPv4-mapped IPv6 addresses with private IPs" do + stub_dns_resolution("::ffff:192.168.1.1") + assert_nil SsrfProtection.resolve_public_ip("mapped-private.example.com") + end + + test "blocks IPv4-mapped IPv6 addresses with link-local IPs" do + stub_dns_resolution("::ffff:169.254.169.254") + assert_nil SsrfProtection.resolve_public_ip("mapped-metadata.example.com") + end + + test "blocks IPv4-mapped IPv6 addresses even with public IPs" do + stub_dns_resolution("::ffff:93.184.216.34") + assert_nil SsrfProtection.resolve_public_ip("mapped-public.example.com") + end + + test "blocks IPv4-compatible IPv6 addresses with private IPs" do + stub_dns_resolution("::192.168.1.1") + assert_nil SsrfProtection.resolve_public_ip("compat-private.example.com") + end + + test "blocks IPv4-compatible IPv6 addresses with link-local IPs" do + stub_dns_resolution("::169.254.169.254") + assert_nil SsrfProtection.resolve_public_ip("compat-metadata.example.com") + end + + test "blocks IPv4-compatible IPv6 addresses even with public IPs" do + stub_dns_resolution("::93.184.216.34") + assert_nil SsrfProtection.resolve_public_ip("compat-public.example.com") + end + private def stub_dns_resolution(*ips) dns_mock = mock("dns")