SSRF: validate the resolved IP, not the URL string

Server-side request forgery happens when your server fetches a URL an attacker supplied, letting them reach anything the server can reach — the cloud metadata endpoint at `169.254.169.254`, internal admin panels, Redis on localhost, or Kubernetes services. String checks on the URL do not work: `localhost` has hundreds of spellings (`127.1`, `0177.0.0.1`, `[::1]`, `2130706433`, a DNS name the attacker points at `127.0.0.1`), and a permitted host can 302-redirect to a forbidden one. The control is to resolve the hostname yourself, reject every address in a private, loopback, link-local or unique-local range, then connect to *that verified IP* rather than re-resolving — and to disable redirect following, or re-run the whole check on each hop. In cloud environments, enforce IMDSv2 and egress rules as well, because the application-level check will eventually be bypassed.

At a glance

LanguagesAny server that fetches a URL: webhooks, link previews, PDF/image renderers, importers, XML parsers, avatar fetchers
Ranges to reject127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10, 0.0.0.0/8, ::1, fc00::/7, fe80::/10, and IPv4-mapped IPv6
Key targets169.254.169.254 (cloud metadata), metadata.google.internal, localhost services, .svc.cluster.local
Bypasses to expectDecimal and octal IPs, DNS rebinding, 302 redirects, open redirects on an allowlisted host, non-HTTP schemes (file:, gopher:, dict:)
Typical impactCloud credential theft leading to full account compromise; internal service access; port scanning
Not a fixRejecting URLs containing 'localhost' or '127.0.0.1', or validating before resolution

Vulnerable and fixed, side by side

Python

Vulnerable

import requests

def fetch_preview(url: str) -> str:
    if "localhost" in url or "127.0.0.1" in url:
        raise ValueError("blocked")
    return requests.get(url, timeout=5).text

# http://169.254.169.254/latest/meta-data/iam/security-credentials/
# http://2130706433/  (decimal for 127.0.0.1)
# http://attacker.tld/  -> 302 -> http://169.254.169.254/

Secure

import ipaddress
import socket
from urllib.parse import urlsplit

import requests

ALLOWED_SCHEMES = {"http", "https"}
ALLOWED_PORTS = {80, 443}


def _resolve_public(host: str) -> str:
    """Every A/AAAA record must be public; return one verified address."""
    infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
    addresses = {info[4][0] for info in infos}
    if not addresses:
        raise ValueError("unresolvable host")
    for address in addresses:
        ip = ipaddress.ip_address(address)
        if ip.version == 6 and ip.ipv4_mapped:
            ip = ip.ipv4_mapped
        if (ip.is_private or ip.is_loopback or ip.is_link_local
                or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
            raise ValueError(f"blocked address {ip}")
    return sorted(addresses)[0]


def fetch_preview(url: str) -> str:
    parts = urlsplit(url)
    if parts.scheme not in ALLOWED_SCHEMES or not parts.hostname:
        raise ValueError("blocked scheme")
    if (parts.port or (443 if parts.scheme == "https" else 80)) not in ALLOWED_PORTS:
        raise ValueError("blocked port")

    verified_ip = _resolve_public(parts.hostname)

    # Connect to the address we checked, keeping the Host header and TLS SNI
    # intact. Re-resolving here is what DNS rebinding attacks rely on.
    target = parts._replace(netloc=verified_ip if parts.port is None
                            else f"{verified_ip}:{parts.port}").geturl()
    response = requests.get(
        target,
        headers={"Host": parts.netloc},
        allow_redirects=False,          # re-run the whole check per hop instead
        timeout=5,
        stream=True,
    )
    if response.status_code in {301, 302, 303, 307, 308}:
        raise ValueError("redirects are not followed")
    return response.raw.read(1_000_000, decode_content=True).decode("utf-8", "replace")

Checking every resolved address, not just the first, matters: a host with one public and one private A record passes a first-record-only check. Pinning the connection to the verified IP closes the time-of-check/time-of-use gap that DNS rebinding exploits. For TLS, verification still runs against the original hostname via SNI plus the Host header.

Node.js

Vulnerable

const res = await fetch(req.body.webhookUrl);   // follows redirects by default

Secure

const dns = require("node:dns/promises");
const net = require("node:net");
const ipaddr = require("ipaddr.js");

const BLOCKED = ["unspecified", "private", "loopback", "linkLocal",
                 "uniqueLocal", "reserved", "carrierGradeNat", "broadcast"];

async function assertPublic(hostname) {
  const records = await dns.lookup(hostname, { all: true, verbatim: true });
  if (records.length === 0) throw new Error("unresolvable");
  for (const { address } of records) {
    let ip = ipaddr.parse(address);
    if (ip.kind() === "ipv6" && ip.isIPv4MappedAddress()) ip = ip.toIPv4Address();
    if (BLOCKED.includes(ip.range())) throw new Error(`blocked ${address}`);
  }
  return records[0].address;
}

const url = new URL(req.body.webhookUrl);
if (!["http:", "https:"].includes(url.protocol)) throw new Error("blocked scheme");
if (net.isIP(url.hostname) === 0) await assertPublic(url.hostname);
else await assertPublic(url.hostname);

const res = await fetch(url, {
  redirect: "manual",                     // fetch follows redirects unless told not to
  signal: AbortSignal.timeout(5000),
});

`fetch` follows up to 20 redirects by default, so a permitted host is enough for an attacker who controls it. `redirect: "manual"` makes each hop an explicit decision. Node's `dns.lookup` honours the system resolver, so also apply an egress policy — the application check is one layer.

How to find it in your codebase

  • Grep for outbound fetches with a non-literal URL: `rg -n 'requests\.(get|post)|httpx\.|urlopen|fetch\(|axios\.|HttpClient|curl_exec'` and check where each URL comes from.
  • Semgrep `python.requests.security.disabled-cert-validation` alongside `python.django.security.audit.ssrf`; CodeQL `py/full-ssrf` and `js/request-forgery` follow the taint through helpers.
  • Enumerate the features that fetch on a user's behalf: webhooks, OAuth discovery, OpenID configuration URLs, link unfurling, PDF/HTML-to-image renderers, XML external entities, `git clone` of a supplied remote, and image resizers.
  • Test with decimal (`http://2130706433/`), octal, IPv6 (`http://[::1]/`), IPv4-mapped (`http://[::ffff:127.0.0.1]/`), a redirect chain, and a DNS name whose TTL is 0 and which alternates answers.

Fix checklist

  1. Prefer an allowlist of exact hostnames when the set of legitimate destinations is known — that is a much stronger control than any denylist.
  2. Otherwise: parse the URL, allow only http/https and ports 80/443, resolve the hostname, and reject if *any* resolved address is private, loopback, link-local, reserved or IPv4-mapped-private.
  3. Connect to the verified IP with the original Host header and SNI, so the address cannot change between check and use.
  4. Disable automatic redirect following; if redirects are required, re-run the full validation on each hop and cap the count.
  5. Set a timeout, a response size cap, and never return the raw upstream body or error to the caller — blind SSRF is much less useful than one that echoes.
  6. Enforce IMDSv2 (or disable the metadata endpoint), and put an egress firewall or an authenticated forward proxy in front of the service so an application-level miss is not fatal.

Fixes we shipped

Each of these is a pull request Orbis AppSec opened against a real open-source repository.

How Server-Side Request Forgery happens in Node.js and how to fix it

The order-flow service in a Node.js e-commerce backend built an outbound fetch() URL by directly concatenating a configurable `sendingOrder.url` value with a query string, with no validation of protocol or destination. This allowed order data—including customer and payment-adjacent information—to be silently redirected to an attacker-controlled endpoint simply by changing a config value or environment variable.

How gitlab.bandit.B501 happens in Python and how to fix it

The `proverbia-scraper.py` script disabled TLS certificate verification on its `requests.get()` call and silenced the resulting security warnings, exposing the scraper to man-in-the-middle attacks. The fix removes the `verify=False` flag and the warning suppression, restoring proper certificate validation while keeping the existing 30-second timeout intact.

How Server-Side Request Forgery (SSRF) happens in Node.js and how to fix it

A critical Server-Side Request Forgery (SSRF) vulnerability in the ldfetch CLI tool allowed attackers to access internal cloud metadata services and local files through unvalidated URL arguments. The fix introduces strict protocol validation with an explicit opt-in flag for local file access, transforming a dangerous default into a secure-by-design implementation.

How Server-Side Request Forgery (SSRF) happens in Go HTTP handlers and how to fix it

A Server-Side Request Forgery (SSRF) vulnerability was discovered in `internal/web/controller/server.go` where the `applySubTemplate` endpoint accepted arbitrary URLs from user input and passed them directly to `serverService.ApplySubTemplateFromGithub()` without any host validation. An attacker could exploit this to make the server issue HTTP requests to internal network resources, cloud metadata endpoints, or redirect-controlled destinations. The fix introduces a strict allowlist that restrict

How SSRF via Vulnerable Dependency Versions Happens in Node.js and How to Fix It

A permissive semver range in `package.json` allowed npm to install axios versions vulnerable to SSRF (CVE-2024-39338). By bumping the minimum version from `^1.6.0` to `^1.7.4`, all downstream consumers of this SDK are now protected from server-side request forgery attacks. This critical fix required changing just one line in the dependency manifest.

How Server-Side Request Forgery (SSRF) happens in JavaScript and how to fix it

A critical Server-Side Request Forgery (SSRF) vulnerability was discovered in playground.html where the `__forEachRdfMessageChunkFromUrl` function fetched user-controlled URLs without validating against private IP ranges or internal network addresses. The fix introduces a comprehensive `__isBlockedFetchUrl` validation function that blocks requests to localhost, private IP ranges, and link-local addresses before any fetch occurs.

How Server-Side Request Forgery happens in Python FastAPI and how to fix it

A critical Server-Side Request Forgery (SSRF) vulnerability was discovered in app.py where the `/parse` and `/parse-video` endpoints accepted user-supplied URLs with only substring validation. The application checked if 'doubao.com' appeared anywhere in the URL string, allowing attackers to bypass this check and access internal services, cloud metadata endpoints, or scan the internal network. The fix implemented proper hostname parsing with an allowlist of legitimate domains.

How Server-Side Request Forgery happens in Node.js maintenance scripts and how to fix it

A critical Server-Side Request Forgery (SSRF) vulnerability was discovered in `maintenance/getImages.js`, where the `getImage()` function passed database-sourced URLs directly to `axios.get()` without any validation. An attacker who could modify the elements database could redirect these requests to internal network resources — including AWS cloud metadata endpoints — potentially exposing IAM credentials and other sensitive infrastructure data. The fix introduces a strict URL allowlist that limi

Browse every ssrf case study

Frequently asked questions

Why is blocking 127.0.0.1 and localhost not enough?

The same address has many textual forms — `127.1`, `0177.0.0.1`, `2130706433`, `[::1]`, `[::ffff:127.0.0.1]`, `127.0.0.1.nip.io` — and an attacker can simply register a domain whose A record is `127.0.0.1`. None of those contain the strings you blocked. Resolve the name and judge the resulting address.

What is DNS rebinding and does IP validation stop it?

The attacker serves a very short TTL and returns a public address to your validation lookup, then a private one to the lookup your HTTP client makes moments later. Validating the IP but then handing the *hostname* to the client is exactly the window it exploits. Connect to the address you verified — pin the IP, keep the Host header — and the second lookup never happens.

Does an allowlist of domains solve it completely?

It is the strongest application-level control, but two things still break it: an open redirect on an allowlisted domain, and an allowlisted domain whose DNS the attacker can influence. Keep redirect following off, and re-validate after any hop you do allow.

Is SSRF still serious if the response is never shown to the user?

Yes. Blind SSRF still lets an attacker reach internal endpoints and cause side effects — POSTing to an internal admin API, triggering a queue job, or scanning ports by timing the difference between a refused connection and a timeout. Cloud metadata is the common escalation because a single GET returns credentials.

Let Orbis AppSec find these for you

Orbis AppSec scans your GitHub repositories, traces the taint from source to sink, and opens a pull request with the fix applied and verified.

Try Orbis AppSec

Authoritative sources