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

← All articles