flawopen.com/ssrf/Ruby
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.
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-uriURI.openfollows HTTP redirects, and for a string that is not an absolute URL it falls back toKernel#open, so/etc/hostnamereads a local file. Passing request data to it gives the caller both the network and the file system.IPAddr#private? is not enoughprivate?covers only10/8,172.16/12,192.168/16andfc00::/7.127.0.0.1,169.254.169.254,0.0.0.0and100.64.0.0/10pass, andResolv.getaddressreturns only one of the host's addresses.Check-then-fetch and DNS rebindingResolv.getaddressresolves the name at check time andNet::HTTPresolves it again when it connects. A DNS server with a short TTL can return a public address to the check and127.0.0.1to the connection, so the check proves nothing.Net::HTTP#ipaddr= and an explicit nil proxyNet::HTTP#ipaddr=makes the client connect to the given IP while theHostheader and TLS still use the hostname, so the checked address is the one used.Net::HTTPdoes not follow redirects, and passingnilas the proxy address stops it from picking uphttp_proxyfrom the environment, which would make the proxy the real destination.IPAddr#ipv4_mapped? and blocked rangesIPAddr#ipv4_mapped?and#nativeturn::ffff:127.0.0.1into127.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
- CWE reference for the weakness described in this guide: CWE-918
- OWASP Top 10:2025 — A01:2025 Broken Access Control
- OWASP API Security Top 10 — API7:2023 Server Side Request Forgery
- D3-OTF Outbound Traffic Filtering — This guide's outbound network restriction limits which destinations the service or host can reach.
Step-by-Step Attack Flow
A user supplies a URL
A Rails action passes params[:url] to URI.open, Net::HTTP, Faraday or HTTParty.
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.
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.
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
# 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
# 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
- ✓Add a Semgrep rule that flags
paramsreachingURI.open,Kernel#open,Net::HTTP, Faraday or HTTParty, and review every outbound call that takes a user-supplied URL. - ✓Never call
URI.openon request data; parse withURI.parseand requireURI::HTTP(which includes HTTPS) with a hostname. - ✓Resolve every address with
Resolv.getaddresses, reject any that falls in a blocked range afternative, and connect withNet::HTTP.new(host, port, nil)plusipaddr=. - ✓Do not follow redirects, set
open_timeoutandread_timeout, and stop reading once the body passes a fixed size. - ✓Enforce IMDSv2 with a hop limit of 1 and restrict egress with a firewall or security group as a second layer.