What it is
SSRF is a flaw that lets an attacker choose where your server sends an HTTP request. The application takes a URL from the user — to fetch a link preview, call a webhook, import data — and fetches it. Because the request originates INSIDE your trusted network, it reaches things the attacker cannot reach from the internet.
Why you should care
If anywhere in your code takes a URL from a request and calls HttpClient.GetAsync(url), fetch(url), or requests.get(url), you have an SSRF until you prove otherwise.
What makes SSRF different is that its payoff is wildly out of proportion to how harmless the feature looks. An endpoint that "fetches the page title for a link field" is the smallest ticket in the sprint, yet on EC2 it reads 169.254.169.254 and returns the instance's own IAM credentials; in Kubernetes it reaches kube-apiserver and every service without mTLS.
SSRF is also the class of bug a firewall cannot save you from: the request leaves from your own service, through exactly the egress you deliberately opened for it.
How the attack works
The sequence is always four steps, and step ② is the bug.
sequenceDiagram autonumber actor A as Attacker participant App as Your app<br/>(inside the VPC) participant Meta as 169.254.169.254<br/>(metadata service) A->>App: POST /preview<br/>url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ Note over App: The app TRUSTS url and fetches it.<br/>It never checks where url points. App->>Meta: GET /latest/meta-data/iam/... Meta-->>App: 200 — AccessKeyId, SecretAccessKey, Token App-->>A: 200 — the "page" it just fetchedWhy it works: step ② never checks where the URL points. The app sits inside the trusted network, so it reaches what the attacker cannot reach from outside — that is the whole value of this bug.
Three variants that naive fixes miss:
| Variant | Payload | Why it slips through |
|---|---|---|
| Redirect | An external URL returns 302 Location: http://169.254.169.254/ | You validated the initial URL; the HTTP client follows the redirect |
| DNS rebinding | attacker.com serves TTL=0: first lookup a public IP, second 169.254.169.254 | You validate then connect — two different resolutions (TOCTOU) |
| Blind SSRF | The app returns no body | Still measurable via response time, status code, or a DNS callback |
0177.0.0.1, [::1], 127.1 and 2130706433 are all 127.0.0.1. Any fix that compares strings loses here.
Diagram description: Four-step sequence diagram: the attacker sends POST /preview with a url pointing at 169.254.169.254; the app does not validate the url and fetches the metadata service directly; the metadata service returns AccessKeyId, SecretAccessKey and Token; the app returns that content to the attacker. The defect is the step where the app trusts the url.
Concrete example
The feature: a user pastes a link, the app fetches the page title to show a preview.
The exploit request and the real response on an EC2 instance still serving IMDSv1:
POST /api/preview HTTP/1.1Host: app.example.comContent-Type: application/json {"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/app-role"}HTTP/1.1 200 OKContent-Type: application/json {"title":null,"body":"{\n \"Code\": \"Success\",\n \"AccessKeyId\": \"ASIA…\",\n \"SecretAccessKey\": \"wJal…\",\n \"Token\": \"IQoJb3…\",\n \"Expiration\": \"2026-08-23T10:14:22Z\"\n}"}There is no clever exploit here: it is a different URL in the exact field the feature invites you to type a URL into. See the language tabs below for the vulnerable and fixed code.
[HttpPost("/api/preview")]public async Task<IActionResult> Preview([FromBody] PreviewRequest req){ // Không kiểm gì. Không phải vì tác giả bất cẩn — mà vì "lấy tiêu đề trang" // nghe như một tính năng không có mặt bảo mật nào. using var http = new HttpClient(); var body = await http.GetStringAsync(req.Url); return Ok(new { title = ExtractTitle(body), body });}@app.post("/api/preview")def preview(): url = request.json["url"] # requests.get đi theo redirect theo MẶC ĐỊNH, nên kể cả khi có kiểm url ở # trên thì một 302 vẫn đưa request tới bất cứ đâu. resp = requests.get(url, timeout=3) return {"title": extract_title(resp.text), "body": resp.text}What happened in the wild
Capital One, July 2019 — ~100 million records. A misconfigured WAF allowed SSRF; the request reached the EC2 metadata service, retrieved credentials for the *****-WAF-Role, and that role held ListBuckets/GetObject on the S3 buckets holding credit-card application data. The DOJ criminal complaint describes exactly this chain. It is the clearest illustration of the point in block 2: the bug sat in a peripheral component, the damage landed in the data tier.
CVE-2026-31847 (Django URLValidator) is the same story at library level: the validator accepts a URL that urlopen later resolves differently — the TOCTOU pattern from the variants table in block 3. The write-up is in the SecLab feed.
How to defend
Application-level allowlist (mandatory)
mandatoryNot a blocklist. A blocklist is a race you lose, because 0177.0.0.1, 127.1, [::ffff:169.254.169.254] and DNS rebinding are all new spellings of the same destination. Allowlist exact hosts, and if the feature genuinely must fetch arbitrary URLs, that work belongs in a separate service with no credentials and no VPC membership.
Three things must be true at once; missing any one leaves the fix open:
- Resolve DNS once, check that IP, then connect to that IP — never reconnect by hostname. This is the only way to close DNS rebinding.
- Turn off automatic redirect following (
AllowAutoRedirect = false), or re-validate from scratch at every hop. - Allow
http/httpsonly.file:,gopher:,dict:andftp:have no business here.
/// <summary>/// Ba việc phải cùng đúng, và thứ tự của chúng là phần dễ làm sai nhất:/// kiểm host → resolve MỘT lần → kết nối tới chính IP vừa kiểm.////// Nếu bước cuối kết nối lại bằng hostname thì cả hàm này vô nghĩa: giữa lúc kiểm/// và lúc kết nối, DNS của kẻ tấn công trả về một IP khác (DNS rebinding)./// </summary>public sealed class SafeFetcher{ private static readonly HashSet<string> AllowedHosts = new(StringComparer.OrdinalIgnoreCase) { "images.partner.example", "cdn.partner.example" }; private readonly HttpClient _http; public SafeFetcher(HttpClient http) => _http = http; public async Task<string> FetchAsync(string rawUrl, CancellationToken ct) { if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var uri)) throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Not a URL"); // file:, gopher:, dict: không có lý do tồn tại ở một endpoint lấy preview. if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Only http(s)"); if (!AllowedHosts.Contains(uri.Host)) throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Host not allowed"); // Resolve MỘT lần. Đây là lần resolve duy nhất trong toàn bộ luồng. var addresses = await Dns.GetHostAddressesAsync(uri.Host, ct); if (addresses.Length == 0) throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Does not resolve"); foreach (var ip in addresses) if (IsInternal(ip)) throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Resolves internally"); // Kết nối tới chính IP vừa kiểm; Host header giữ tên miền để TLS/SNI và vhost còn đúng. var pinned = new UriBuilder(uri) { Host = addresses[0].ToString() }.Uri; using var request = new HttpRequestMessage(HttpMethod.Get, pinned); request.Headers.Host = uri.Host; using var response = await _http.SendAsync(request, ct); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(ct); } private static bool IsInternal(IPAddress ip) { if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4(); if (IPAddress.IsLoopback(ip)) return true; var b = ip.GetAddressBytes(); if (ip.AddressFamily == AddressFamily.InterNetwork) return b[0] == 10 // 10/8 || (b[0] == 172 && b[1] >= 16 && b[1] < 32) // 172.16/12 || (b[0] == 192 && b[1] == 168) // 192.168/16 || (b[0] == 169 && b[1] == 254) // link-local + metadata || b[0] == 0 || b[0] == 127; return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || (b[0] & 0xfe) == 0xfc; // fc00::/7 unique-local }} // Đăng ký: AllowAutoRedirect = false là phần KHÔNG ĐƯỢC bỏ. Không có nó, một host// trong allowlist chỉ cần trả 302 là mọi kiểm ở trên bị vòng qua.services.AddHttpClient<SafeFetcher>() .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false, ConnectTimeout = TimeSpan.FromSeconds(3), });import ipaddressimport socketfrom urllib.parse import urlsplit, urlunsplit import requests ALLOWED_HOSTS = {"images.partner.example", "cdn.partner.example"} class UnsafeUrl(Exception): pass def fetch(raw_url: str, timeout: float = 3.0) -> str: parts = urlsplit(raw_url) if parts.scheme not in ("http", "https"): raise UnsafeUrl("only http(s)") if parts.hostname not in ALLOWED_HOSTS: raise UnsafeUrl("host not allowed") # Resolve MỘT lần, kiểm mọi bản ghi trả về — không chỉ bản ghi đầu. infos = socket.getaddrinfo(parts.hostname, parts.port or 443, proto=socket.IPPROTO_TCP) addrs = [ipaddress.ip_address(i[4][0]) for i in infos] if not addrs: raise UnsafeUrl("does not resolve") for ip in addrs: if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: raise UnsafeUrl(f"resolves internally: {ip}") # Kết nối tới chính IP vừa kiểm; Host header giữ tên miền. pinned = urlunsplit(parts._replace(netloc=str(addrs[0]))) resp = requests.get( pinned, headers={"Host": parts.hostname}, allow_redirects=False, # KHÔNG bỏ dòng này timeout=timeout, ) resp.raise_for_status() return resp.textNetwork egress
The service that makes outbound calls runs in its own subnet, its NetworkPolicy opens only the destinations it needs, and it blocks 169.254.0.0/16, 10/8, 172.16/12, 192.168/16, ::1/128, fd00::/8. This layer is what saves you when layer 1 has a bug — and layer 1 will have a bug.
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: preview-service-egress namespace: appspec: podSelector: matchLabels: { app: preview-service } policyTypes: [Egress] egress: # DNS phải mở, nếu không service không resolve được gì. - to: - namespaceSelector: matchLabels: { kubernetes.io/metadata.name: kube-system } podSelector: matchLabels: { k8s-app: kube-dns } ports: - { protocol: UDP, port: 53 } - { protocol: TCP, port: 53 } # Internet, TRỪ mọi dải nội bộ. except là phần làm nên giá trị của policy này: # thiếu nó thì "mở ra Internet" cũng mở luôn đường vào metadata service. - to: - ipBlock: cidr: 0.0.0.0/0 except: - 169.254.0.0/16 # link-local — metadata service của cloud - 10.0.0.0/8 - 172.16.0.0/12 - 192.168.0.0/16 - 127.0.0.0/8 ports: - { protocol: TCP, port: 443 }Move the prize out of reach
Require IMDSv2 (HttpTokens: required) — it needs a PUT to mint a token, and a GET-based SSRF cannot issue a PUT. On AWS: aws ec2 modify-instance-metadata-options --http-tokens required --http-put-response-hop-limit 1. This is the highest effect-per-effort control on the whole page.
Verifying the fix
A fix without a test does not exist — the next refactor removes it and nobody notices. Four checks, one per variant from block 3:
1. Unit test — all four spellings of localhost must be rejected. See the csharp / test tab. The point is that the test takes the odd-spelling list as its input data, so when someone discovers a new spelling, the test is where it gets recorded.
2. Redirect test. Stand up a test server returning 302 to 169.254.169.254, call it through the fetcher, and assert the fetcher refuses. If that test passes with no code change, you have not disabled auto-redirect.
3. Check from the outside — prove egress is closed. From inside the pod or instance:
# Must TIME OUT, not return 200:curl -s -m 3 http://169.254.169.254/latest/meta-data/ ; echo "exit=$?"# exit=28 (timeout) passes. exit=0 means layer 2 is still open.4. Check IMDSv2 is required.
aws ec2 describe-instances --instance-ids i-… \ --query 'Reservations[].Instances[].MetadataOptions.HttpTokens' --output text# must print: requiredAll four belong in CI, not in a runbook nobody runs.
public class SafeFetcherTests{ // Mỗi dòng ở đây là một cách viết khác của cùng một đích. Đây là chỗ để thêm khi // có người tìm ra cách viết mới — không phải một câu if mới trong code sản phẩm. [Theory] [InlineData("http://127.0.0.1/")] [InlineData("http://127.1/")] [InlineData("http://0177.0.0.1/")] [InlineData("http://2130706433/")] [InlineData("http://[::1]/")] [InlineData("http://[::ffff:169.254.169.254]/")] [InlineData("http://169.254.169.254/latest/meta-data/")] [InlineData("file:///etc/passwd")] [InlineData("gopher://127.0.0.1:6379/_INFO")] public async Task Rejects_every_spelling_of_internal(string url) { var fetcher = new SafeFetcher(new HttpClient()); await Assert.ThrowsAsync<ApplicationGeneralException>( () => fetcher.FetchAsync(url, CancellationToken.None)); } /// <summary> /// Biến thể redirect. Test này là lý do AllowAutoRedirect = false tồn tại — bỏ dòng /// cấu hình đó ra thì đúng test này đỏ, không phải một test nào khác. /// </summary> [Fact] public async Task Does_not_follow_redirect_to_metadata() { await using var evil = TestServer.Returning( 302, location: "http://169.254.169.254/latest/meta-data/"); var fetcher = new SafeFetcher(HttpClientWith(allowAutoRedirect: false)); var ex = await Record.ExceptionAsync( () => fetcher.FetchAsync(evil.Url, CancellationToken.None)); Assert.NotNull(ex); Assert.DoesNotContain("meta-data", ex!.Message); }}Common mistakes
Five "fixes" that look right and are not. The first four have each shipped as the fix in a real CVE.
| The "fix" | Why it is wrong |
|---|---|
Blocklist 127.0.0.1, localhost, 169.254.169.254 | 127.1, 0177.0.0.1, 2130706433, [::ffff:127.0.0.1] and localtest.me all walk past it |
| Regex-check the hostname before fetching | DNS rebinding: the resolution at check time and at connect time are two different lookups |
| Allowlist the domain, keep auto-redirect on | An allowlisted domain can 302 anywhere |
Validate Uri.Host but fetch the original Uri | Right URL, but the connection resolves a second time |
| Block only at the NetworkPolicy, never fix the code | Correct and necessary, but SSRF still scans internally within the egress you left open, and the bug returns the moment the service is deployed elsewhere |
One more, at a different level: treating blind SSRF as low risk. No response body is still enough to map the internal network by timing, and enough to hit a POST endpoint with side effects.
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…