SecLab

SSRF — Server-side request forgery

A01V13API7C10CWE-918
01

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.

02

Why you should care

Relevance: CoreExpected: L2

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.

03

How the attack works

The sequence is always four steps, and step ② is the bug.

Diagram source
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 fetched

Why 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:

VariantPayloadWhy it slips through
RedirectAn external URL returns 302 Location: http://169.254.169.254/You validated the initial URL; the HTTP client follows the redirect
DNS rebindingattacker.com serves TTL=0: first lookup a public IP, second 169.254.169.254You validate then connect — two different resolutions (TOCTOU)
Blind SSRFThe app returns no bodyStill 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.

04

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:

HTTP
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
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.

C#The whole bug is line 8: url comes from the user and is fetched directly.
[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 });}
PythonThe same bug in Python — and `requests` follows redirects by default.
@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}
05

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.

06

How to defend

Layer 1

Application-level allowlist (mandatory)

mandatory

Not 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:

  1. Resolve DNS once, check that IP, then connect to that IP — never reconnect by hostname. This is the only way to close DNS rebinding.
  2. Turn off automatic redirect following (AllowAutoRedirect = false), or re-validate from scratch at every hop.
  3. Allow http/https only. file:, gopher:, dict: and ftp: have no business here.
C# · Layer 1Layer 1: host allowlist, resolve once, connect to the very IP you checked, never follow redirects.
/// <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),    });
Python · Layer 1The same three rules: allowlist, resolve once, connect to the checked IP, allow_redirects=False.
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.text
Layer 2

Network 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.

YAML · Layer 2Layer 2: a NetworkPolicy blocking link-local and RFC1918, opening only the egress needed.
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 }
Layer 3

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.

07

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:

Shell
# 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.

Shell
aws ec2 describe-instances --instance-ids i-… \  --query 'Reservations[].Instances[].MetadataOptions.HttpTokens' --output text# must print: required

All four belong in CI, not in a runbook nobody runs.

C#The odd-spelling list is test DATA, so a newly discovered spelling has a home.
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);    }}
08

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.254127.1, 0177.0.0.1, 2130706433, [::ffff:127.0.0.1] and localtest.me all walk past it
Regex-check the hostname before fetchingDNS rebinding: the resolution at check time and at connect time are two different lookups
Allowlist the domain, keep auto-redirect onAn allowlisted domain can 302 anywhere
Validate Uri.Host but fetch the original UriRight URL, but the connection resolves a second time
Block only at the NetworkPolicy, never fix the codeCorrect 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.

09

References

Tier 1API7:2023 Server Side Request Forgery · OWASP · API Security Top 10 · 2023
Tier 1C10: Stop Server Side Request Forgery · OWASP · Proactive Controls · 2024
Tier 1Use IMDSv2 — Retrieve instance metadata · Amazon Web Services · EC2 User Guide · 2025-06
Tier 2Server-side request forgery (SSRF) · PortSwigger · Web Security Academy
Tier 2Server Side Request Forgery Prevention Cheat Sheet · OWASP · Cheat Sheet Series
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

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.

Sign upSign in

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…