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.
| Languages | Any server that fetches a URL: webhooks, link previews, PDF/image renderers, importers, XML parsers, avatar fetchers |
| Ranges to reject | 127.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 targets | 169.254.169.254 (cloud metadata), metadata.google.internal, localhost services, .svc.cluster.local |
| Bypasses to expect | Decimal and octal IPs, DNS rebinding, 302 redirects, open redirects on an allowlisted host, non-HTTP schemes (file:, gopher:, dict:) |
| Typical impact | Cloud credential theft leading to full account compromise; internal service access; port scanning |
| Not a fix | Rejecting URLs containing 'localhost' or '127.0.0.1', or validating before resolution |
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.
Vulnerable
const res = await fetch(req.body.webhookUrl); // follows redirects by defaultSecure
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.
Each of these is a pull request Orbis AppSec opened against a real open-source repository.
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.
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.
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
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.
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.
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
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
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
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.
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.
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.
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.
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