ruby / fetch safe
Any handler that fetches an attacker-supplied URL is an SSRF
risk. Without a guard, a URL like http://localhost:5432/ or
http://169.254.169.254/latest/meta-data/ reaches internal
services or cloud metadata. The literal cases are easy. The
hard cases are hostnames that resolve to those addresses, and
hostnames whose DNS answer changes between the check and the
connect.
Fetch::Safe is a shared helper used by image fetches
(see ruby / cloudflare), URL previews, and
anywhere else the app reaches out to a user-controlled URL.
Reject unsafe schemes and hosts
Parse the URL once. Require http or https. Resolve the
host. Reject if any answer falls in a private, loopback,
link-local, or multicast range:
require "ipaddr"
require "resolv"
module Fetch
module Safe
UNSAFE_IPV4_RANGES = [
IPAddr.new("0.0.0.0/8"), # current network
IPAddr.new("10.0.0.0/8"), # RFC1918
IPAddr.new("127.0.0.0/8"), # loopback
IPAddr.new("169.254.0.0/16"), # link-local (cloud metadata)
IPAddr.new("172.16.0.0/12"), # RFC1918
IPAddr.new("192.168.0.0/16"), # RFC1918
IPAddr.new("224.0.0.0/4"), # multicast
IPAddr.new("255.255.255.255/32") # broadcast
].freeze
UNSAFE_IPV6_RANGES = [
IPAddr.new("::1/128"), # loopback
IPAddr.new("fc00::/7"), # unique local
IPAddr.new("fe80::/10"), # link-local
IPAddr.new("ff00::/8") # multicast
].freeze
def self.safe_uri(url)
uri = URI.parse(url.to_s.strip)
if uri.scheme != "http" && uri.scheme != "https"
return nil
end
if uri.host.nil?
return nil
end
if !safe_resolved_addresses?(uri.host)
return nil
end
uri
rescue URI::InvalidURIError
nil
end
def self.safe_resolved_addresses?(host)
addresses = Resolv.getaddresses(host)
if addresses.empty?
return false
end
addresses.all? { |a| safe_ip?(a) }
end
def self.safe_ip?(addr)
ip = IPAddr.new(addr)
ranges = ip.ipv4? ? UNSAFE_IPV4_RANGES : UNSAFE_IPV6_RANGES
ranges.none? { |r| r.include?(ip) }
rescue IPAddr::InvalidAddressError
false
end
end
end
A literal IP like 127.0.0.1 is caught the same way as a
hostname that resolves to one. A multi-A-record hostname is
rejected if any answer is unsafe, not just the first one used
to connect.
Fetch with redirects
Re-check every redirect hop. A safe initial URL can redirect to an internal address:
def self.fetch(url, max_redirects: 5)
current = safe_uri(url)
if current.nil?
return [nil, "unsafe URL"]
end
max_redirects.times do
resp = HTTP
.timeout(connect: 3, read: 10)
.follow(false)
.get(current.to_s)
if [301, 302, 303, 307, 308].include?(resp.code)
current = safe_uri(resp.headers["Location"].to_s)
if current.nil?
return [nil, "unsafe redirect"]
end
next
end
return [resp, nil]
end
[nil, "too many redirects"]
end
Disable the HTTP client's automatic redirect following and
drive it manually so each Location goes back through
safe_uri.
DNS rebinding (TOCTOU)
DNS resolution at parse time and the resolution the HTTP
client does when connecting are two separate queries. A
hostile resolver can answer the first with a public IP and the
second with 127.0.0.1. Closing that window requires pinning
the connection to the IP that was validated:
addr = Resolv.getaddresses(uri.host).find { |a| safe_ip?(a) }
HTTP
.headers("Host" => uri.host)
.get("#{uri.scheme}://#{addr}#{uri.path}")
This trades convenience (no SNI, certificate hostname checks need care) for closing the rebinding gap. Worth it on any endpoint that fetches an unvetted URL.
Tests
Stub Resolv.getaddresses to drive the resolver from tests
without depending on real DNS. The thin client calls it
directly, so a stub_class in the test
framework is enough:
def test_rejects_host_resolving_to_loopback
stub_class(Resolv, getaddresses: ->(_) { ["127.0.0.1"] })
ok { Fetch::Safe.safe_uri("http://evil.example.com/x") == nil }
end
def test_allows_public_address
stub_class(Resolv, getaddresses: ->(_) { ["93.184.216.34"] })
ok { Fetch::Safe.safe_uri("http://example.com/x") != nil }
end
def test_rejects_unsafe_redirect
stub_class(Resolv, getaddresses: ->(host) {
host == "ok.example.com" ? ["93.184.216.34"] : ["127.0.0.1"]
})
stub_request(:get, "http://ok.example.com/")
.to_return(status: 302, headers: {"Location" => "http://internal/"})
_, err = Fetch::Safe.fetch("http://ok.example.com/")
ok { err == "unsafe redirect" }
end