Server-side request forgery, SSRF, is a vulnerability where an attacker makes your server issue HTTP requests to a destination of their choosing. It sounds mild until you remember where your server sits: inside your network, holding a cloud identity, able to reach internal admin panels, databases, and the cloud metadata endpoint that hands out credentials. A feature as ordinary as "fetch the image at this URL" can become the pivot into your entire environment.
The shape of the bug
SSRF appears anywhere your application takes a user-supplied URL or host and makes a request to it: webhook registration, URL preview and link unfurling, PDF and image generation from a URL, importing data from a remote source, or an integration that calls "any endpoint you configure." The vulnerable pattern is trivially small:
import requests
url = request.args["url"] # attacker controls this
resp = requests.get(url) # your server fetches whatever they want
return resp.content
Point that at http://169.254.169.254/latest/meta-data/ on a cloud host and, without further hardening, an attacker can read instance metadata and potentially cloud credentials. Point it at http://localhost:6379/ and they are talking to your Redis.
Allowlist, do not blocklist
The instinct is to block "bad" destinations: reject localhost, reject 169.254.169.254. Blocklists lose, because there are too many ways to write the same address: decimal IP notation, IPv6-mapped addresses, 0.0.0.0, alternate DNS names that resolve to internal ranges. The robust design flips the default. Decide up front which destinations are legitimate and permit only those:
ALLOWED_HOSTS = {"api.partner.com", "cdn.partner.com"}
from urllib.parse import urlparse
def is_allowed(url: str) -> bool:
parsed = urlparse(url)
return parsed.scheme in {"https"} and parsed.hostname in ALLOWED_HOSTS
When your integrations are a known set, an allowlist is both simpler and far safer than trying to enumerate everything malicious.
When the destination cannot be fixed: validate the resolved IP
Some features genuinely must fetch arbitrary user URLs. There, the defense is to resolve the hostname yourself and reject the request if it points at a private, loopback, link-local, or reserved range, then connect to the IP you validated:
import ipaddress, socket
def resolve_public_ip(hostname: str) -> str:
ip = socket.gethostbyname(hostname)
addr = ipaddress.ip_address(ip)
if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved:
raise ValueError("Destination resolves to a non-public address")
return ip
Two traps remain. First, DNS rebinding: an attacker's domain can pass your check with a public IP, then re-resolve to an internal one before the actual request. Defend by connecting to the exact IP you validated rather than re-resolving the hostname. Second, redirects: a permitted URL can 302 to an internal one, so disable automatic redirect following and re-validate every hop:
resp = requests.get(url, allow_redirects=False, timeout=5)
Restrict the URL scheme, and expect blind SSRF
Attackers do not limit themselves to http. Depending on the client library, schemes like file://, gopher://, ftp://, and dict:// can read local files or speak to internal services in ways a naive validator never anticipates. Always constrain the scheme to an explicit allowlist, in practice just https, before any other check runs.
Also plan for blind SSRF, where the response never comes back to the attacker but the request still fires. A webhook validator that fetches a URL and discards the body still lets an attacker probe internal ports by timing, or trigger state-changing internal endpoints. The mitigations are the same, allowlisting, IP validation, egress control, but the detection story is harder, so lean on network-level logging of unexpected outbound connections from application hosts.
Lock down the network and the metadata endpoint
Application-layer checks should not be your only line. Put the outbound fetch behind an egress proxy that enforces the allowlist at the network level, so a code bug cannot bypass it. On cloud hosts, require IMDSv2 (the session-token version of the instance metadata service), which defeats the simple SSRF-to-metadata path that older, header-less metadata access allowed. And apply timeouts and response-size limits so SSRF cannot double as a denial-of-service amplifier.
Because SSRF is fundamentally about runtime request behavior, the most reliable way to confirm your defenses hold is to test the deployed application from the outside. A dynamic application security scan can feed metadata and internal-range payloads at your URL-fetching endpoints and report which ones actually reach where they should not.
Checklist
- Allowlist destinations wherever the set of valid hosts is known
- For arbitrary URLs, resolve and reject private, loopback, link-local, reserved IPs
- Connect to the validated IP to defeat DNS rebinding
- Disable automatic redirects; re-validate each hop
- Enforce timeouts and response-size caps
- Require IMDSv2 and restrict egress at the network layer
- Test with SSRF payloads against the running app
How Safeguard helps
The allowlisting and IP-validation logic above is code you write; a scanner will not author it for you, and we say that plainly. Safeguard helps on the flanks. SSRF is often introduced through a dependency, an HTTP client, a URL parser, or a link-unfurling library, and when one of those carries an SSRF or request-smuggling CVE, our software composition analysis surfaces it in your tree with fix guidance. When a patched version exists, automated remediation opens the upgrade PR so the known-vulnerable client is replaced quickly. Teams comparing our reachability-aware approach to prioritization against other tools often start with our comparison against Snyk.
Get started
Add allowlisting and IP validation to your URL-fetching code, require IMDSv2, then let Safeguard watch the HTTP libraries underneath. Create a free project at app.safeguard.sh/register and follow the docs at docs.safeguard.sh.