Attach the policy to the connection, not the host name string. Allow only http and https on ports 80 and 443 without credentials; read IP literals with IPAddr, refuse other IPv4 notations, resolve names and reject any non-public address; pin the connection with Net::HTTP#ipaddr=; vet every redirect; disable the proxy and cap time and bytes.
- Find every place a request value reaches an HTTP client: Net::HTTP, URI.open (open-uri), Faraday, HTTParty, Down, image or webhook helpers, and anything that follows redirects by default.
- Parse the URL with URI.parse and allow only http and https, ports 80 and 443, and no userinfo.
- Turn the host into addresses: IPAddr.new for a literal (it accepts only standard notation, so refuse a host whose last label is a number that IPAddr cannot read), otherwise resolve it with Addrinfo.getaddrinfo. Refuse the whole answer if any address is loopback, private, link-local, reserved or an IPv4-mapped or NAT64 form of one.
- Connect to the address you vetted: Net::HTTP.new(host, port, nil) with http.ipaddr = vetted_ip keeps the Host header and TLS name while the socket goes to that address, and the nil proxy argument stops http_proxy from rerouting it.
- Do not let the client follow redirects. Read Location yourself, resolve it against the current URL with URI.join, and run the whole policy again; stop after a fixed number of hops.
- Set open_timeout, read_timeout and write_timeout, read the body with read_body and stop past a byte limit, and add regression tests that name internal addresses in every notation, through DNS and through a redirect.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
# No validation at all: parse the URL and hand the host to the resolver.
def self.target(url, network)
uri = URI.parse(url)
[uri, network.resolve(uri.hostname).first]
endWith IMPL=vulnerable, 12 of 15 tests fail (10 exploit tests).
The fix: vet every address
# Special-purpose IPv4 ranges (the IANA registry), IPv6 outside global unicast, documentation.
NON_PUBLIC_V4 = %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.0.0.0/24 192.0.2.0/24 192.88.99.0/24 192.168.0.0/16 198.18.0.0/15
198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4].map { IPAddr.new(_1) }.freeze
GLOBAL_V6 = IPAddr.new("2000::/3")
DOCUMENTATION_V6 = IPAddr.new("2001:db8::/32")
NAT64 = IPAddr.new("64:ff9b::/96")
# An IPv4 address embedded in IPv6 (IPv4-mapped or NAT64) is judged as the IPv4 address it reaches.
def self.public_address?(text)
ip = IPAddr.new(text)
ip = ip.native if ip.ipv4_mapped?
ip = IPAddr.new(ip.to_i & 0xffff_ffff, Socket::AF_INET) if ip.ipv6? && NAT64.include?(ip)
return GLOBAL_V6.include?(ip) && !DOCUMENTATION_V6.include?(ip) if ip.ipv6?
NON_PUBLIC_V4.none? { _1.include?(ip) }
end
# The URL Standard's test for an IPv4 host: the last label is all digits, or 0x and hex digits.
# IPAddr reads only dotted decimal, while getaddrinfo also reads 2130706433, 0177.0.0.1 and
# 0x7f.0.0.1 as 127.0.0.1, so a host like that that IPAddr cannot read is refused.
def self.ends_in_number?(host)
last = host.chomp(".").split(".").last.to_s.downcase
last.match?(/\A0x[0-9a-f]*\z/) || last.match?(/\A[0-9]+\z/)
end
def self.addresses_for(host, network)
literal = (IPAddr.new(host).to_s unless host.include?("/")) rescue nil
return [literal] if literal
raise BlockedRequest, "IPv4 address in a non-standard notation" if ends_in_number?(host)
addresses = network.resolve(host)
raise BlockedRequest, "host did not resolve" if addresses.empty?
addresses
end
def self.vet(url, network)
uri = URI.parse(url)
raise BlockedRequest, "scheme not allowed" unless FetchTransport::ALLOWED_SCHEMES.include?(uri.scheme)
raise BlockedRequest, "credentials in URL not allowed" if uri.userinfo
raise BlockedRequest, "missing host" if uri.hostname.to_s.empty?
raise BlockedRequest, "port not allowed" unless FetchTransport::ALLOWED_PORTS.include?(uri.port)
addresses = addresses_for(uri.hostname, network)
# Refuse the whole answer if any address is not public.
raise BlockedRequest, "non-public address" unless addresses.all? { public_address?(_1) }
[uri, addresses.first]
rescue URI::Error, IPAddr::Error
raise BlockedRequest, "invalid URL"
endWith IMPL=fixed, 15 of 15 tests pass.
The fix: re-validate every redirect
# A 3xx is a new destination, not permission to follow: run the whole policy on every hop and
# pin each connection to its own vetted address.
def self.fetch(url, network:)
target = url
(FetchTransport::MAX_REDIRECTS + 1).times do
uri, ip = vet(target, network)
result = FetchTransport.request(uri, ip:, network:, max_bytes: FetchTransport::MAX_RESPONSE_BYTES)
if (300..399).cover?(result[:status]) && result[:location]
target = URI.join(uri, result[:location]).to_s # relative to this hop's URL; vetted again
next
end
return FetchTransport.summarise(result)
end
raise BlockedRequest, "too many redirects"
endWith IMPL=fixed, 15 of 15 tests pass.
A fix that looks safe but is not
BLOCKED_SUBSTRINGS = ["localhost", "127.0.0.1", "0.0.0.0", "169.254", "10.", "192.168", "172.16",
"::1", "metadata", "internal"].freeze
def self.screen(host)
raise FetchTransport::BlockedRequest, "host is blocked" if BLOCKED_SUBSTRINGS.any? { host.to_s.downcase.include?(_1) }
endWith IMPL=wrong, 7 of 15 tests fail (5 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: each payload names an internal destination a different way. The secure outcome is the
# same every time: 400 and no connection to the internal service.
test("exploit: loopback IPv4 literal") { assert_blocked("http://127.0.0.1/latest/meta-data/") }
test("exploit: loopback as a decimal integer") { assert_blocked("http://2130706433/") }
test("exploit: loopback in octal") { assert_blocked("http://0177.0.0.1/") }
test("exploit: loopback in hex") { assert_blocked("http://0x7f.0.0.1/") }
test("exploit: IPv6 loopback literal") { assert_blocked("http://[::1]/") }
test("exploit: IPv4-mapped IPv6 loopback") { assert_blocked("http://[::ffff:127.0.0.1]/") }
test("exploit: cloud metadata address by host name") { assert_blocked("http://metadata.internal.example/latest/meta-data/") }
test("exploit: private address by host name") { assert_blocked("http://db.internal.example/") }
test("exploit: a name that resolves to loopback") { assert_blocked("http://rebind.example/") }
test("exploit: redirect from an allowed host to an internal one") { assert_blocked("http://files.partner.example.test/redirect-internal") }- A host name blocklist checks a string, not a destination: 2130706433, 0177.0.0.1 and 0x7f.0.0.1 all reach 127.0.0.1 through getaddrinfo, and an ordinary-looking name can resolve to an internal address. That is this card's wrong implementation.
- Validating the resolved address and then letting Net::HTTP resolve the name again leaves a DNS rebinding window. Pin the connection with ipaddr= to the address you checked.
- Net::HTTP.new reads http_proxy from the environment by default (p_addr = :ENV); with a proxy the connection goes to the proxy, not the vetted address. Pass nil to turn it off, or put the policy in the proxy instead.
- open-uri (URI.open) and many HTTP gems follow redirects by default, and a redirect from an allowed host to 169.254.169.254 is the classic bypass. Follow redirects yourself and vet each hop.
- IPAddr#private? does not cover loopback, link-local, carrier-grade NAT or the special-purpose ranges; check them all explicitly, and unwrap IPv4-mapped (ipv4_mapped? and native) and NAT64 addresses before judging them.
- The ssrf_filter gem packages the same resolve, vet, pin and per-hop checks for Ruby; whichever you use, keep these regression tests.
- An egress firewall or proxy that denies internal ranges is a second layer, not a replacement: application-level checks still decide which public hosts a feature may reach.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs ssrf/ruby-rails does this for you, in Docker if you do not have Ruby).
- The tests use a fake network: a resolver that maps fixture names to labelled addresses and a dialer that routes connections to loopback servers and counts every attempt on an internal address, so a blocked request must cause zero attempts, not just an error.
- Against your own endpoint, try http://127.0.0.1/, http://2130706433/, http://[::ffff:127.0.0.1]/, a name you control that resolves to 10.0.0.1, and an allowed host that redirects to http://169.254.169.254/; each must be refused before any connection to an internal address.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/ruby, p/owasp-top-ten, p/security-audit | No findings on any implementation. The packs have no rule for Net::HTTP reaching a request-controlled host, and the fetcher's host and address handling is invisible to them, so the vulnerable, wrong and fixed fetchers look the same.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config p/ruby --config p/owasp-top-ten --config p/security-audit app config (59 rules on 9 files) |
| CodeQL | rb/request-forgery | rb/request-forgery marks the host or URL of a Net::HTTP request as an SSRF sink. With params passed directly it would flag the vulnerable and wrong fetchers (the host comes from the request) and not the fixed one (the address is vetted and pinned). As written the cards read params through the controller and reach the fetcher through UrlFetch.current (const_get), which the models do not follow, so a run is not expected to report them.Checked: github/codeql main at b5b8165; query and Ruby models read, not executed |
| Brakeman | SSRFBrakeman 8.1.0 | No SSRF warning on any implementation. Brakeman's SSRF check follows request data to an HTTP client within one controller or model; the fetcher is a separate class reached through UrlFetch.current, so it is not flagged. The only warning is Missing Encryption (ForceSSL), because the card has no config/environments/production.rb, which the card leaves out as the other Rails cards do.Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q --no-pager -f json /card (all checks) |
| Component | Version |
|---|---|
| Language | Ruby |
| Runtime | Ruby 4.0.7 |
| Framework | Ruby on Rails 8.1.4 (API controller) with Net::HTTP (net-http 0.9.1) and IPAddr (ipaddr 1.2.8) |
| rails (railties, actionpack) | 8.1.4 |
| net-http (default gem, Net::HTTP#ipaddr=) | 0.9.1 |
| ipaddr (default gem) | 1.2.8 |
| minitest | 6.0.6 |
| minitest-reporters (JUnitReporter) | 1.8.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ssrf/ruby-railsfolder.
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install# Expected: every test passes
IMPL=fixed BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/fetch_test.rb
# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/fetch_test.rb
# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/fetch_test.rbWithout Ruby installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=fixed -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/fetch_test.rb'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=vulnerable -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/fetch_test.rb'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=wrong -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/fetch_test.rb'Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs ssrf/ruby-rails runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- Debian 13 (trixie) in Docker, on macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- ruby 4.0.7 (ruby:4.0.7-trixie image, linux/arm64)
- net-http 0.9.1
- ipaddr 1.2.8
- bundler 4.0.20
- rails 8.1.4
- minitest 6.0.6
- minitest-reporters 1.8.0
- semgrep 1.180.0
- brakeman 8.1.0
- Matrix
- fixed: 15 of 15 tests pass; vulnerable: 12 of 15 tests fail (10 exploit tests); wrong: 7 of 15 tests fail (5 exploit tests)
- Test results
- fixed 15/15 pass; vulnerable 12/15 fail (all 10 exploit tests, plus the ftp URL, which Net::HTTP turns into a failed request (502) instead of a 400, and the over-size response); wrong 7/15 fail (5 exploit tests: decimal, octal and hex loopback, the name that resolves to loopback and the redirect to the metadata host; plus the ftp URL and the over-size response). Each test run in a container with --network=none after install; the fake network's loopback servers run inside the container.
- Dependency freshness
- Same Gemfile.lock as the other Action Pack-only Rails cards: Rails 8.1.4 (2026-09-24), with uri held at 1.1.1 and prettyprint at 0.2.0 because 1.1.2 and 0.2.1 (both 2026-10-08) were three days old and are not security releases. net-http 0.9.1 and ipaddr 1.2.8 are the default gems of Ruby 4.0.7 (2026-09-15), the latest 4.0 release. OSV reported no known vulnerabilities for any pin.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Server-Side Request Forgery Prevention Cheat Sheet
- CWE-918: Server-Side Request Forgery (SSRF)
- Ruby net-http: Net::HTTP#ipaddr=
- Ruby net-http: Net::HTTP.new and the proxy from the environment
- Ruby ipaddr: IPAddr (loopback?, private?, link_local?, ipv4_mapped?, native)
- Ruby socket: Addrinfo.getaddrinfo
- WHATWG URL Standard: ends in a number checker
- IANA IPv4 Special-Purpose Address Registry
- RFC 6052: IPv6 Addressing of IPv4/IPv6 Translators (64:ff9b::/96)
- ssrf_filter gem
Attack patterns: