guest@websec: ~/websec/13-ssrf — zsh — 100×40 by SaKKo ENTH code:

guest@websec ~ % man 13-ssrf

13 · Server-Side Request Forgery

Any feature where our server fetches a user's URL (avatar from link, webhook, link preview, PDF render) can be turned against us. Attackers can't reach our internal network. Our server can, and SSRF makes it fetch for them.

What attackers aim for

  • Cloud metadata (169.254.169.254) hands out the server's cloud credentials. This was behind the 2019 Capital One breach.
  • Internal services that trust anything on the private network.
  • localhost services on the same box.

Why simple blocklists fail

Blocking "localhost" in the string doesn't work. IPs can be written many ways, hostnames can point inside, and redirects can lead inside. Try it:

sim: URL fetch validatoruses your browser's real URL parser
try:
checkdecisionwhat actually happens

Defenses

  1. Avoid it if you can: take uploads instead, or only fetch from known hosts.
  2. Otherwise: resolve the hostname yourself, block private IPs, then connect to that exact IP.
  3. Don't follow redirects blindly.
  4. Short timeouts, and never echo the response back.
  5. On AWS, require IMDSv2 so a plain GET can't read metadata.
Rails does nothing here. Watch for URI.open(params[…]), HTTP clients with user URLs, and attach(io: URI.open(url)). The ssrf_filter gem does the hard part.
# Gemfile: gem "ssrf_filter"
def import_avatar(url)
  uri = URI.parse(url)
  raise ArgumentError, "http(s) only" unless %w[http https].include?(uri.scheme)
  response = SsrfFilter.get(uri, max_redirects: 0)   # resolves, blocks private IPs, pins IP
  raise "not an image" unless response["content-type"].to_s.start_with?("image/")
  avatar.attach(io: StringIO.new(response.body), filename: "avatar")
rescue SsrfFilter::Error, URI::InvalidURIError
  errors.add(:avatar_url, "can't be fetched")         # generic message, no body echo
end

avatar.attach(io: URI.open(params[:avatar_url]), filename: "a")   # SSRF
// Node: request-filtering-agent blocks private IPs after DNS resolution.
// It needs an agent-aware client (node-fetch, axios, got): Node's built-in fetch ignores `agent`.
import fetch from "node-fetch";
import { useAgent } from "request-filtering-agent";
const res = await fetch(url, { agent: useAgent(url), redirect: "manual" });
await fetch(req.body.url);    // SSRF