FlawOpen

flawopen.com/ssrf/Ruby

● CWE-918 · High
FlawOpen Security Research

Ruby SSRF: secure Net::HTTP and Rails

Ruby SSRF lets user URLs reach internal services and cloud metadata. Validate and pin each resolved IP; disable redirects and restrict egress.

💡 Plain English Explainer (ELI5)

Imagine a hotel delivery desk that accepts either a full street address or a note meant for the hotel’s own storeroom. Ruby URI.open is similarly flexible: it can fetch a web URL, follow its redirect, or fall back to Kernel#open for a local path such as /etc/hostname. A user-supplied string can therefore choose both network destinations and local files. The hardened route accepts only HTTP(S), checks every resolved IP, and uses Net::HTTP#ipaddr= to connect to the checked address while keeping the hostname for Host and TLS. An explicit nil proxy prevents environment settings from sending the request through an unchecked intermediary; IPAddr#ipv4_mapped? catches IPv4 addresses written in IPv6 form.

Core Concepts & Subsystem Terms

URI.open from open-uri
URI.open follows HTTP redirects, and for a string that is not an absolute URL it falls back to Kernel#open, so /etc/hostname reads a local file. Passing request data to it gives the caller both the network and the file system.
IPAddr#private? is not enough
private? covers only 10/8, 172.16/12, 192.168/16 and fc00::/7. 127.0.0.1, 169.254.169.254, 0.0.0.0 and 100.64.0.0/10 pass, and Resolv.getaddress returns only one of the host's addresses.
Check-then-fetch and DNS rebinding
Resolv.getaddress resolves the name at check time and Net::HTTP resolves it again when it connects. A DNS server with a short TTL can return a public address to the check and 127.0.0.1 to the connection, so the check proves nothing.
Net::HTTP#ipaddr= and an explicit nil proxy
Net::HTTP#ipaddr= makes the client connect to the given IP while the Host header and TLS still use the hostname, so the checked address is the one used. Net::HTTP does not follow redirects, and passing nil as the proxy address stops it from picking up http_proxy from the environment, which would make the proxy the real destination.
IPAddr#ipv4_mapped? and blocked ranges
IPAddr#ipv4_mapped? and #native turn ::ffff:127.0.0.1 into 127.0.0.1; checking against an explicit list of loopback, private, link-local, CGNAT, unspecified and multicast ranges then covers both IPv4 and IPv6.

Framework mappings

Step-by-Step Attack Flow

Step 1

A user supplies a URL

A Rails action passes params[:url] to URI.open, Net::HTTP, Faraday or HTTParty.

Step 2

The app connects to an internal address

The URL names 127.0.0.1, a private host, 169.254.169.254, a local file path, a hostname that rebinds, or a public page that redirects inward. The benign test value http://127.0.0.1:8080/health illustrates the case.

Step 3

An internal endpoint may be reachable

A Rails server may reach admin routes, HTTP-exposed cache services, or metadata endpoints if network policy permits; each target may still require authentication or block the request.

Step 4

The attacker reads secrets or pivots

The response path creates the disclosure: the Rails preview returns bytes read by URI.open, while the webhook can return Net::HTTP status. Return only the data the feature needs, and keep internal credentials and response bodies out of caller-controlled fetch results.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
# fetch_controller.rb: both actions let the caller choose the destination
require "ipaddr"
require "net/http"
require "open-uri"
require "resolv"

class FetchController < ApplicationController
  # open-uri follows redirects and falls back to Kernel#open for file paths
  def preview
    render plain: URI.open(params[:url]).read[0, 5000]
  end

  # private? misses 127.0.0.1 and 169.254.169.254; Net::HTTP resolves again
  def test_webhook
    uri = URI.parse(params[:url])
    return head(:bad_request) if IPAddr.new(Resolv.getaddress(uri.host)).private?

    response = Net::HTTP.post(uri, { ping: true }.to_json, "Content-Type" => "application/json")
    render json: { status: response.code.to_i }
  end
end
HARDENED SECURE PATCH
# fetch_controller.rb: validate every address, then connect to that IP
require "ipaddr"
require "net/http"
require "resolv"

class FetchController < ApplicationController
  BLOCKED = %w[
    0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12
    192.168.0.0/16 224.0.0.0/3 ::/128 ::1/128 fc00::/7 fe80::/10 ff00::/8
  ].map { |range| IPAddr.new(range) }.freeze
  MAX_BYTES = 1_000_000
  FAILURES = [ArgumentError, SocketError, Net::OpenTimeout, Net::ReadTimeout].freeze

  # only fetch_public reaches the network
  def preview
    _, text = fetch_public(params.require(:url))
    render plain: text[0, 5000]
  rescue *FAILURES
    head :bad_request
  end

  def test_webhook
    response, = fetch_public(params.require(:url), body: { ping: true }.to_json)
    render json: { status: response.code.to_i }
  rescue *FAILURES
    head :bad_request
  end

  private

  # unwrap IPv4-mapped IPv6, then reject every blocked range
  def public_ip?(ip)
    ip = ip.native if ip.ipv4_mapped?
    BLOCKED.none? { |range| range.include?(ip) }
  end

  # nil proxy, pinned IP, no redirects, timeouts, capped body
  def fetch_public(raw, body: nil)
    uri = URI.parse(raw)
    raise ArgumentError, "only http and https URLs are allowed" unless uri.is_a?(URI::HTTP) && uri.hostname.present?

    addresses = Resolv.getaddresses(uri.hostname).map { |a| IPAddr.new(a) }
    raise ArgumentError, "destination is not a public address" if addresses.empty? || !addresses.all? { |ip| public_ip?(ip) }

    http = Net::HTTP.new(uri.hostname, uri.port, nil)
    http.ipaddr = addresses.first.to_s
    http.use_ssl = uri.scheme == "https"
    http.open_timeout = 3
    http.read_timeout = 5
    request = body ? Net::HTTP::Post.new(uri, "Content-Type" => "application/json") : Net::HTTP::Get.new(uri)
    request.body = body if body

    text = +""
    response = http.request(request) do |res|
      res.read_body do |chunk|
        text << chunk
        raise ArgumentError, "response too large" if text.bytesize > MAX_BYTES
      end
    end
    [response, text]
  end
end

Engineering & System Hardening Checklist

Sources