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.

axios 1.18.1 CVE-2026-101898: HTTP/2 Ignores proxy and lookup

A dependency upgrade moved `axios` from 1.18.1 to 1.20.0 to pick up the fix for CVE-2026-101898, in which proxy and DNS resolution settings supplied through axios request configuration were not applied on the HTTP/2 request path. Applications that rely on `config.proxy`, `HTTP_PROXY`/`NO_PROXY` environment variables, or a custom `lookup` function as an SSRF or egress control could have those controls silently skipped for HTTP/2 requests.

Cloudflare Worker Reverse Proxy SSRF via String Concatenation

A Cloudflare Worker reverse proxy built its upstream request URL by string-concatenating an environment variable with the incoming request's pathname and search string, letting a crafted path or query redirect requests to attacker-chosen hosts. The fix replaces concatenation with the `URL` object's `pathname` and `search` setters, which normalize input against the configured origin instead of blindly appending it.

fast-uri 3.1.2 IDN Host Bypass: CVE-2026-13676 Upgrade

A dependency tree in this repository resolved `fast-uri` 3.1.2, a version affected by CVE-2026-13676, in which improper Unicode hostname canonicalization lets a crafted URI parse to one host while the HTTP client connects to another. Because `fast-uri` is what `ajv` uses to parse and resolve URIs for `format: "uri"` validation and `$ref` resolution, any host allowlist built on its parsed output could be bypassed. The fix upgrades the resolved copy to `fast-uri` 4.1.4 and pins it with an npm `ove

boardcards.js parseArgs() SSRF: --board Flag Reaches Metadata IPs

The `parseArgs()` function in boardcards.js accepted a user-controlled `--board` command-line argument and passed it directly to fetch() calls without validating the URL scheme or hostname. An attacker could supply `http://169.254.169.254/latest/meta-data/` or other internal addresses to extract cloud credentials or scan internal networks.

fetch() with Automatic Redirects Leaks HTTPS Trust to api.tomys.top

The `withProxy()` fetch wrapper allowed automatic HTTP redirects from `api.tomys.top`, trusting the external API's domain integrity without validating final destinations. A compromised or malicious redirect response could send users to attacker-controlled phishing sites while maintaining HTTPS protocol and hostname checks that falsely appear secure.

Meta.get() Honored @uploadURL From Script Headers: SSRF Fix

A userscript manager's metadata parser accepted the `@uploadURL` directive from a script's own header block, so any installed or auto-updated script could silently redirect the options-page `uploadScript()` fetch to an attacker-chosen destination — including loopback, link-local metadata endpoints, and LAN addresses. The fix makes `Meta.get()` ignore `@uploadURL` when it appears in a script header, leaving the User Metadata field (validated in `Meta.getUserMeta()`) as the only way to set it, and

Unvalidated thumbnailUrl Passed to axios.get(): SSRF Risk

A server-side image helper passes a `thumbnailUrl` value — sourced from 3D-print file metadata returned by an OctoPrint instance — straight into `axios.get()` with no scheme, host, or IP validation. Anyone who can write that metadata (a crafted G-code file, or a compromised/spoofed OctoPrint endpoint) turns the application into an HTTP proxy for internal networks and cloud metadata services. The accompanying pull request bumps `react-router-dom` from 7.9.1 to 7.18.4 to pick up the CVE-2026-21884

esearch() SSRF: requests.get() Trusted Any Host in the URL

A citation format-conversion script used by an AI research skill built HTTP URLs from user-supplied PMIDs, DOIs, arXiv IDs, and free-text queries, then passed the resulting string straight to `requests.get()` with no check that it still pointed at an intended API host. The fix introduces an `ALLOWED_HOSTS` set containing the three real upstream APIs and an `_is_allowed_url()` helper that compares `urlparse(url).hostname` against it before the request is issued. This closes a CWE-918 server-side

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