SSRF Explained: Server-Side Request Forgery Attack Examples and Prevention
Server-side request forgery (SSRF) lets an attacker make the application's own server send requests anywhere that server can reach: localhost, internal networks, cloud metadata. It is CWE-918, entered OWASP's Top 10 as A10:2021, and in the 2025 edition was folded into A01 Broken Access Control. This guide covers the mechanics, a reproducible local lab, detection, and fixes.
What SSRF is and why it keeps mattering
SSRF occurs when a web application fetches a remote resource without validating the user-supplied URL. The attacker co-opts the application's own network position, which is trusted by everything behind the firewall, VPN, or network ACL, so SSRF turns a firewall into a non-factor. OWASP introduced it as A10:2021 with 9,503 recorded occurrences and 385 mapped CVEs in that dataset, an average weighted exploit score of 8.28 / 10, and an average weighted impact of 6.72 / 10.
The 2025 OWASP Top 10 no longer lists SSRF as a standalone category: the release notes state it was rolled into A01:2025 Broken Access Control, the same category as IDOR. That is a taxonomy change, not a risk change: the underlying weakness, CWE-918 (Server-Side Request Forgery), is the same, and the cloud-metadata angle keeps it at the top of every bug-bounty program's payout table.
Three real incidents show the range of impact:
| Incident | SSRF role | Outcome |
|---|---|---|
| Capital One, 2019 | SSRF through a misconfigured web application firewall reached the EC2 metadata service (169.254.169.254), returned IAM role credentials, which were then used against S3. | ~100M US and ~6M Canadian credit-application records exposed; a former AWS engineer was charged by the DOJ. |
| CVE-2021-40438, Apache httpd | Crafted request URI-path made mod_proxy forward to an origin server chosen by the remote user: SSRF in the reverse proxy itself. | Affects 2.4.48 and earlier; fixed in 2.4.49 (2021-09-16). Reverse proxies are a first-class SSRF attack surface. |
| CVE-2026-15409, SonicWall SMA1000 | SSRF in the appliance. | CVSS 10.0; added to the CISA Known Exploited Vulnerabilities catalog on 2026-07-14 with confirmed exploitation linked to ransomware campaigns, evidence SSRF remains an active initial-access vector. |
In MITRE ATT&CK terms, SSRF is the mechanism behind T1190 (Exploit Public-Facing Application), and the metadata-credential variant lands squarely in T1552.005 (Unsecured Credentials: Cloud Instance Metadata API).
Attack anatomy: three trust relationships
SSRF attacks abuse the trust other systems place in the vulnerable application's network position. Concretely:
- Against the server itself: pointing the fetch at
http://127.0.0.1:8080/adminor another localhost port. Access-control checks that trust loopback traffic (or admin interfaces bound to non-public ports) are bypassed. - Against back-end systems: pointing it at RFC 1918 addresses such as
http://192.168.0.68/admin. Internal services are often unauthenticated because the network topology was the only control, the gap default-deny Kubernetes NetworkPolicies close from the other side. - Against cloud metadata: pointing it at
http://169.254.169.254/latest/meta-data/iam/security-credentials/to steal temporary IAM credentials, then using them against S3, SSM, or the control plane (the Capital One chain).
1. Attacker
Submits a URL pointing at 169.254.169.254
2. Application
Fetches it from inside the VPC
3. Metadata service
Returns temporary IAM role credentials
4. Attacker
Uses the credentials against S3 or the control plane
The request flow is a simple relay: the attacker never talks to the target directly:
Attacker ──crafted URL──▶ Vulnerable App ──HTTP/FTP/gopher...──▶ Target (internal)
│ │
└────────────── response ──────────────────┘
│
└── response relayed to attacker (full SSRF)
or no response relayed (blind SSRF)A typical request pair, the classic "stock API" pattern, where the app fetches a URL the user supplies:
POST /product/stock HTTP/1.1 Content-Type: application/x-www-form-urlencoded stockApi=http://localhost/admin
The server performs GET /admin from its own loopback interface and relays the response. The same primitive works with the metadata endpoint substituted for localhost. Any server-side fetcher is an entry point, including XML parsers that resolve external entities (XXE is a common route to SSRF).
Reproducible local lab
Everything below runs against local containers on your machine, with no live targets and no weaponized payloads. The lab emulates the AWS metadata endpoint at its real address, 169.254.169.254, using a Docker network with a link-local subnet.
docker-compose.yml
services:
app:
build: .
ports:
- "8080:8080"
networks:
lab:
ipv4_address: 169.254.0.10
metadata:
image: nginx:alpine
volumes:
- ./metadata.conf:/etc/nginx/conf.d/default.conf:ro
networks:
lab:
ipv4_address: 169.254.169.254
networks:
lab:
ipam:
config:
- subnet: 169.254.0.0/16Docker on a few hosts refuses link-local subnets. If docker compose up errors on the subnet, switch the network to 172.28.0.0/16, give the metadata container 172.28.0.66, and use that address in every walkthrough URL below. The behavior is identical, only the IP changes.
Dockerfile
FROM python:3.12-slim WORKDIR /app RUN pip install --no-cache-dir flask requests COPY app.py . EXPOSE 8080 CMD ["python", "app.py"]
app.py: vulnerable fetch endpoint
from flask import Flask, Response, request
import requests
app = Flask(__name__)
@app.get("/fetch")
def fetch():
"""Fetch a URL server-side. The url parameter is fully attacker-controlled."""
url = request.args.get("url", "")
if not url.startswith(("http://", "https://")):
return "only http(s) allowed", 400
resp = requests.get(url, timeout=5) # follows redirects by default
return Response(resp.content, content_type=resp.headers.get("Content-Type", "text/plain"))
@app.get("/flag")
def flag():
"""Simulates an internal-only admin endpoint behind the network ACL."""
return "internal-only: flag{ssrf-locally-verified}"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)metadata.conf: fake cloud metadata service
server {
listen 80;
location = /latest/meta-data/iam/security-credentials/ {
add_header Content-Type application/json;
return 200 '{"Code":"Success","AccessKeyId":"AKIALABEXAMPLE","SecretAccessKey":"lab-secret","Token":"lab-token","Expiration":"2027-01-01T00:00:00Z"}';
}
location /latest/meta-data/ {
return 200 'lab-metadata-ok';
}
}Run it and exploit it
docker compose up --build
First, confirm the endpoint works as intended against the public internet:
$ curl -s "http://localhost:8080/fetch?url=https://example.com/" | head -1 <!doctype html>
Now the metadata credential theft. This is the Capital One chain in miniature. One request, no authentication:
$ curl -s "http://localhost:8080/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
{"Code":"Success","AccessKeyId":"AKIALABEXAMPLE","SecretAccessKey":"lab-secret","Token":"lab-token","Expiration":"2027-01-01T00:00:00Z"}And the loopback trust bypass: the request reaches the app's own internal endpoint through the same primitive:
$ curl -s "http://localhost:8080/fetch?url=http://127.0.0.1:8080/flag"
internal-only: flag{ssrf-locally-verified}What does not work (and why that teaches you more)
Failed payloads are as instructive as successful ones:
file:///etc/passwd: Python'srequestshas no adapter for the file scheme, so this raisesInvalidSchema. The same is not true of stacks with richer scheme support (curl, Go's stdlib with custom handlers, some Java HTTP clients). Scheme abuse is stack-dependent, which is why OWASP listsfile://,gopher://,dict://,data://andphar://as SSRF schemes to block.- The app's
startswithscheme check rejectsfile://but accepts every attack that matters. Scheme filtering alone is not a defense. - Add a naive deny-list blocking
127.0.0.1and169.254.169.254to the app, then attack through a redirect you control.requestsfollows redirects by default, so a URL likehttp://attacker.example/r?to=http://127.0.0.1:8080/flagsails past the filter. On Linux,http://0.0.0.0:8080/flagalso works: the kernel routes connections to the unspecified address to loopback.
Bypassing common defenses
The pattern to internalize: filters that inspect a string lose to parsers that interpret it differently downstream. OWASP's own guidance is blunt: do not mitigate SSRF with a deny-list or regex. The table below is the standard bypass catalog (PortSwigger's Web Security Academy is the canonical reference):
| Defense | Bypass | Example |
|---|---|---|
| Deny-list on hostname strings | Alternative IP encodings (decimal, octal, hex, short forms), DNS rebinding, redirects, 0.0.0.0. Whether a given encoding works depends on the HTTP stack's resolver, so test in the lab. | 2130706433 (decimal 127.0.0.1), 127.1, 0177.0.0.1, http://0.0.0.0:8080/flag |
| Whitelist prefix check | URL-authority tricks that make the parser and the filter disagree: credentials, fragments, DNS hierarchy, encoding. | https://trusted.com@evil.example/, https://evil.example#trusted.com, https://trusted.com.evil.example/ |
| Scheme allowlist (http/https only) | Cross-scheme redirects and protocol confusion: the first request is https, the redirect target is gopher, dict, or file. | https://attacker.example/redirect → gopher://redis:6379/_... |
| Hostname validation, no re-check on connect | DNS rebinding and TOCTOU races: the hostname resolves to a public IP when validated and to an internal IP when connected. | attacker.example alternates 8.8.8.8 and 127.0.0.1 with a short TTL |
OWASP's A10:2021 guidance calls this out directly: enforce URL scheme, port, and destination with a positive allowlist, disable HTTP redirections, and be aware of URL consistency to avoid DNS-rebinding and TOCTOU races.
How to detect SSRF
Detection works at three layers: static analysis in CI, network signatures, and outbound-flow logging.
Static analysis (Semgrep)
The OWASP cheat sheet points to the public Semgrep registry (q=ssrf) as a starting point. A minimal rule for the pattern above:
rules:
- id: ssrf-unsanitized-url
languages: [python]
severity: WARNING
message: >-
User-controlled URL reaches a network request without SSRF validation
(CWE-918). Validate the scheme, every resolved IP (A + AAAA), and
disable redirects before fetching (OWASP SSRF Prevention Cheat Sheet).
patterns:
- pattern-either:
- pattern: requests.get($URL, ...)
- pattern: requests.post($URL, ...)
- pattern: requests.request($METHOD, $URL, ...)Expect noise: once you ship a guard helper like fetch_safe(), the rule matches your own safe call sites. Add a pattern-not for the helper or move the rule to review-only severity.
Network signatures (Suricata)
Two example signatures (SIDs 1,000,000+ are reserved for local rules, so tune before production):
alert http any any -> any any (msg:"SSRF: cloud metadata path requested"; \ flow:established,to_server; content:"/latest/meta-data/"; http_uri; \ classtype:attempted-info-leak; sid:1000001; rev:1;) alert http any any -> any any (msg:"SSRF: link-local metadata host as destination"; \ flow:established,to_server; content:"169.254.169.254"; http_host; \ classtype:attempted-info-leak; sid:1000002; rev:1;)
Log signals
- Outbound HTTP from the app tier to
169.254.169.254,metadata.google.internal, or any link-local/loopback address. This should never appear in access logs. - App-tier connections to RFC 1918 destinations the application has no business calling (metadata, databases, admin panels).
- Non-HTTP schemes in fetch parameters (
gopher,dict,file). - The redirect flavor of the attack leaves a distinctive two-hop trace: an outbound fetch to an external host followed within milliseconds by a loopback or internal request.
How to prevent SSRF
The OWASP cheat sheet splits prevention into two cases, and the choice of control depends on which one you are in:
| Situation | Control that actually works |
|---|---|
| The app only ever calls identified, trusted applications (internal services, a fixed API) | Positive allowlist: exact hostname/IP list, scheme and port allowlist, redirects disabled. Validate with parser-safe libraries (e.g. Apache Commons Validator in Java, ip-address in JS, ipaddress in Python). |
| The app must fetch arbitrary external URLs (webhooks, avatar uploads, link previews) | Deny-list as a last resort: OWASP explicitly warns it is bypass-prone. Minimum: block metadata endpoints, loopback, RFC 1918, link-local, and multicast ranges for every resolved IP (A and AAAA), pin the connection to the validated IP, disable redirects or re-validate each hop, and allow only http/https. |
| Cloud deployments | Enforce IMDSv2 on AWS (PUT-token based; disable IMDSv1), scope IAM roles on the app tier to the minimum, and put the URL-fetching service behind an egress firewall with deny-by-default rules. |
| Any deployment | Network segmentation: run URL-fetching functionality in its own segment so a compromise does not reach the whole backend. Never echo raw responses to the client when the Content-Type is non-text. |
Python (requests): resolve, validate, pin, no redirects
The core idea: resolve the hostname once, reject the request if any resolved address is non-public, then connect to the validated address, so a DNS rebinding race between validation and connection has nothing to win.
import ipaddress
import socket
import ssl
import urllib.parse
import requests
from requests.adapters import HTTPAdapter
from urllib3 import HTTPConnectionPool, HTTPSConnectionPool, Retry
def _resolve_all(host: str) -> list[str]:
"""Every A/AAAA address for a host, deduplicated."""
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
return list({info[4][0] for info in infos})
def _is_blocked(ip: str) -> bool:
addr = ipaddress.ip_address(ip)
# Normalize IPv4-mapped IPv6 (::ffff:127.0.0.1 -> 127.0.0.1)
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
addr = addr.ipv4_mapped
return (
addr.is_private # RFC 1918 + IPv6 ULA (fc00::/7)
or addr.is_loopback # 127.0.0.0/8, ::1
or addr.is_link_local # 169.254.0.0/16, fe80::/10 - covers 169.254.169.254
or addr.is_multicast # 224.0.0.0/4, ff00::/8
or addr.is_unspecified # 0.0.0.0, ::
or addr.is_reserved # 240.0.0.0/4 and other special ranges
)
class SSRFGuardAdapter(HTTPAdapter):
"""Resolve + validate every IP, then pin the connection to a validated IP.
get_connection_with_tls_context() returns a pool whose host is the
validated IP, so the socket never re-resolves the hostname (no
DNS-rebinding window). Because the pool is keyed by IP, two things must
be restored by hand: the Host header (otherwise requests derives it from
the pool host and sends the IP, breaking virtual-host routing), and the
TLS identity — server_hostname drives SNI while assert_hostname pins the
certificate check to the real name, both set to the original hostname.
Requires requests >= 2.32.2 (get_connection_with_tls_context) / urllib3 2.x
(server_hostname / assert_hostname as documented pool parameters).
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._pools = [] # custom pools bypass the pool manager; track for close()
def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
parsed = urllib.parse.urlsplit(request.url)
host = parsed.hostname or ""
ips = _resolve_all(host)
if not ips or any(_is_blocked(ip) for ip in ips):
raise requests.exceptions.InvalidURL(
f"destination resolves to a blocked address: {host}"
)
# Prefer IPv4 when available (both families are validated above).
ip = next((a for a in ips if ":" not in a), ips[0])
port = parsed.port or (443 if parsed.scheme == "https" else 80)
# The pool is pinned to the IP, so restore the original Host header —
# otherwise requests sends the IP and virtual-host routing breaks. The
# same PreparedRequest is sent by the adapter, so this reaches the wire.
request.headers["Host"] = host if port in (80, 443) else f"{host}:{port}"
pool_kwargs = dict(
maxsize=self._pool_maxsize,
block=self._pool_block,
retries=Retry(0, read=False),
)
if parsed.scheme == "https":
# Carry the caller's TLS policy into the pool: requests passes
# verify (bool or CA-bundle path) and cert (path or (cert, key)).
if verify is False:
pool_kwargs["cert_reqs"] = ssl.CERT_NONE
else:
pool_kwargs["cert_reqs"] = ssl.CERT_REQUIRED
# requests defaults to the certifi bundle; keep the same anchors.
pool_kwargs["ca_certs"] = requests.certs.where() if verify is True else verify
if cert is not None:
if isinstance(cert, tuple):
pool_kwargs["cert_file"], pool_kwargs["key_file"] = cert
else:
pool_kwargs["cert_file"] = cert
pool = HTTPSConnectionPool(
ip, port, server_hostname=host, assert_hostname=host, **pool_kwargs
)
# Socket pinned to the validated IP; SNI (server_hostname) and the
# certificate hostname check (assert_hostname) are validated against
# the real name, and the Host header set above carries it too.
else:
pool = HTTPConnectionPool(ip, port, **pool_kwargs)
self._pools.append(pool) # track so Session.close() closes them too
return pool
def close(self):
super().close()
for pool in self._pools:
pool.close()
def fetch_safe(url: str, timeout: int = 5) -> requests.Response:
with requests.Session() as session:
session.mount("http://", SSRFGuardAdapter())
session.mount("https://", SSRFGuardAdapter())
resp = session.get(url, timeout=timeout, allow_redirects=False)
if resp.is_redirect:
raise requests.exceptions.TooManyRedirects("redirects disabled (SSRF guard)")
return respAgainst the lab, fetch_safe() rejects every payload from the walkthrough: metadata (link-local), loopback, redirects, and file:// all raise before a socket is opened.
Go (net/http): guarded DialContext
Go's transport separates the dial address from the TLS ServerName, so HTTPS keeps working with correct SNI while the connection is pinned to a validated IP:
package main
import (
"context"
"fmt"
"net"
"net/http"
"time"
)
// blocked reports whether ip is loopback, private, link-local, or otherwise
// non-routable. The metadata endpoint 169.254.169.254 is link-local and is
// covered by IsLinkLocalUnicast.
func blocked(ip net.IP) bool {
if v4 := ip.To4(); v4 != nil {
ip = v4 // normalize IPv4-mapped IPv6
}
return !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() ||
ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast()
}
func guardedClient() *http.Client {
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
if len(ips) == 0 {
return nil, fmt.Errorf("no addresses for %q", host)
}
for _, ip := range ips {
if blocked(ip.IP) {
return nil, fmt.Errorf("blocked non-public address %s", ip.IP)
}
}
// Pin the connection to a validated address (no re-resolution).
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
},
}
return &http.Client{
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return fmt.Errorf("redirects disabled (SSRF guard)")
},
Timeout: 10 * time.Second,
}
}Node.js (http/https): pinned lookup, SNI preserved, manual redirects
The example below resolves and validates every address at connect time: ipaddr.js (the OWASP cheat sheet's recommended approach) classifies IPv4 and IPv6 in one call. The code then pins the socket to a validated address, and keeps the original hostname for TLS SNI and certificate validation:
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { lookup } from 'node:dns/promises';
import ipaddr from 'ipaddr.js'; // npm i ipaddr.js — complete IPv4/IPv6 classification
// Reject everything that is not a globally routable unicast address:
// private (RFC 1918), loopback, link-local (incl. 169.254.169.254),
// multicast, unspecified, ULA (fc00::/7), IPv4-mapped, and reserved ranges.
function assertPublic(address) {
const addr = ipaddr.parse(address);
const ip = addr.kind() === 'ipv6' && addr.isIPv4MappedAddress() ? addr.toIPv4Address() : addr;
if (ip.range() !== 'unicast') throw new Error('blocked non-public address ' + address);
}
export function fetchSafe(urlString, { timeout = 5000 } = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlString);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
reject(new Error('scheme not allowed'));
return;
}
const host = url.hostname.replace(/^\[|\]$/g, '');
// IP-literal target: Node skips the lookup override for IPs, so the
// pinned callback never runs — reject before any connection.
if (ipaddr.isValid(host)) assertPublic(host);
// Resolve and validate at connect time, then pin the socket to the
// validated address — no window for DNS rebinding between check and use.
const pinned = (hostname, options, cb) => {
lookup(hostname, { all: true })
.then((records) => {
if (records.length === 0) throw new Error('no addresses for ' + hostname);
for (const { address } of records) assertPublic(address);
// Prefer IPv4 when available (both families are validated above).
const sorted = [...records].sort((a, b) => a.family - b.family);
// Node's lookup contract: options.all -> cb(err, addresses[]),
// otherwise cb(err, address, family).
if (options.all) cb(null, sorted);
else cb(null, sorted[0].address, sorted[0].family);
})
.catch((err) => cb(err));
};
const mod = url.protocol === 'https:' ? httpsRequest : httpRequest;
const req = mod(
url,
{
lookup: pinned, // socket connects to the validated IP
servername: url.hostname, // HTTPS SNI + cert validation keep the hostname
headers: { Host: url.host },
timeout,
},
(res) => {
if (res.statusCode >= 300 && res.statusCode < 400) {
res.resume();
reject(new Error('redirects disabled (SSRF guard)'));
return;
}
resolve(res);
},
);
req.on('timeout', () => req.destroy(new Error('request timed out')));
req.on('error', reject);
req.end();
});
}Network layer and cloud metadata
- Segment remote-resource-fetching functionality into its own network; enforce deny-by-default egress firewall rules so the app tier can only reach what it must (OWASP A10:2021 network-layer guidance).
- AWS: enable IMDSv2 and disable IMDSv1. IMDSv2 requires a PUT-obtained session token plus the
X-aws-ec2-metadata-tokenheader, so a plain GET-based SSRF can no longer read credentials directly. This is a defense-in-depth layer, not a replacement for input validation: an SSRF with full request control can still obtain the token. - GCP and Azure metadata services are reachable at the same link-local address
169.254.169.254(GCP also viametadata.google.internal); the OWASP deny-list table blocks all of them plus RFC 1918, loopback, and multicast ranges as a minimum.
Prevention checklist
| Check | How to verify |
|---|---|
| Every server-side fetch is inventoried (requests, urllib, http, fetch, curl, proxy rules) | grep the codebase + configs; SAST rule in CI flags new call sites |
| Scheme allowlist enforced (http/https) | Send file://, gopher://, dict://; expect rejection |
| All resolved IPs validated (A + AAAA), connection pinned | Point the app at a domain alternating public/private answers with a short TTL (DNS rebinding): connection must still be blocked |
| Redirects disabled or re-validated per hop | Serve a 302 from a host you control to http://169.254.169.254/; expect rejection |
| Metadata endpoints blocked (AWS/GCP/Azure, 169.254.169.254) | Lab walkthrough request: expect block; network signature fires on metadata path |
| Egress firewall deny-by-default from app tier | Attempt app → internal admin port from the app host; observe deny logs |
| IMDSv2 enforced, IMDSv1 disabled (AWS) | aws ec2 describe-instances shows HttpTokens: required on every instance |
| Outbound-to-metadata alerts configured | Trigger the Suricata rule or log query in staging; confirm the alert fires |
Key takeaways
- SSRF converts the application's trusted network position into a proxy for the attacker: firewalls and ACLs are irrelevant once the fetch is attacker-controlled.
- The metadata chain (169.254.169.254 → IAM credentials → S3/control plane) is the highest-impact variant; it drove the Capital One breach and remains a current initial-access vector (CVE-2026-15409, KEV July 2026).
- Allowlists beat deny-lists; deny-lists are a documented last resort. Whatever you use, validate every resolved IP, pin the connection, and disable redirects.
- Detection is cheap relative to the blast radius: SAST in CI, two Suricata signatures, and outbound-flow logs catch the common variants.
Frequently asked questions
- What is server-side request forgery (SSRF)?
- SSRF (CWE-918) is a flaw where an application fetches a URL the user controls without validating it, so the attacker can make the server send requests to localhost, internal networks, or cloud metadata endpoints that are unreachable from the internet.
- Why is the cloud metadata endpoint the most dangerous SSRF target?
- The metadata service at 169.254.169.254 hands out temporary IAM credentials to anything on the instance. An SSRF that reaches it lets the attacker steal those credentials and use them against S3, SSM, or the cloud control plane. That is the chain behind the Capital One breach.
- Is a deny-list of internal IP ranges enough to stop SSRF?
- No. Deny-lists are bypassed with alternative IP encodings, redirects, and DNS rebinding. Use an allowlist of destinations, validate every resolved IP address, pin the connection to the validated address, and disable redirects.
- How do you detect SSRF?
- Combine SAST taint rules in CI (user input reaching an HTTP client), network signatures for requests to metadata and internal addresses, and outbound-flow logs that flag the application server connecting to destinations it never normally reaches.
Kokkuvõte eesti keeles
Server-Side Request Forgery (SSRF) on rünnak, kus ründaja sunnib rakendust tegema päringuid serveri enda nimel, näiteks localhosti, sisemiste teenuste või pilve metaandmete lõpp-punkti (169.254.169.254) poole. Nii saab varastada IAM-mandaate ja pääseda ligi sisemistele süsteemidele, mida tulemüür kaitseb. Peamised kaitsed: positiivne lubatud-URL-ide nimekiri (allowlist), kõigi DNS-ist lahendatud IP-aadresside kontroll, ümbersuunamiste keelamine ning võrgu tasandil deny-by-default egress-tulemüür. AWS-is lülitage sisse IMDSv2 ja keelake IMDSv1. Täielik laborikäik ja koodinäited on ülal inglise keeles.
Sources
- OWASP Top 10:2021: A10 Server-Side Request Forgery
- OWASP Server-Side Request Forgery Prevention Cheat Sheet
- OWASP Top 10:2025: Introduction (SSRF rolled into A01)
- CWE-918: Server-Side Request Forgery
- PortSwigger Web Security Academy: SSRF
- PortSwigger: URL validation bypass cheat sheet
- AWS Security Blog: IMDSv2 defense in depth
- Apache HTTP Server vulnerabilities: CVE-2021-40438
- CISA Known Exploited Vulnerabilities Catalog (CVE-2026-15409)
- MITRE ATT&CK T1190 / T1552.005 (Cloud Instance Metadata API)
- Capital One: 2019 incident facts; systematic analysis (ACM)
- RFC 1918, RFC 3927 (link-local), RFC 4193 (ULA), RFC 5737 (documentation ranges)