# Server-side request forgery (SSRF): how it works, how testers prove it, how to fix it

Source: https://penetrationtestservices.com/vulnerabilities/ssrf/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

Server-side request forgery (SSRF) explained: how it works, where it hides, how testers prove it with evidence, and allow-list code for Python, Node.js, Java and Go.

> **Short answer:** Server-side request forgery (SSRF) is a flaw where an attacker makes your server send a request to a place the attacker chose, such as an internal service or a cloud metadata endpoint. Fix it with an allow-list of destinations, a check of the resolved IP address, redirects off, and network egress rules. Testers prove it with a timestamped callback or a read-back, not a 200 status.

Server-side request forgery (SSRF) is a flaw that lets an attacker make your server send an HTTP request, or another network request, to a destination the attacker chose. CWE-918 describes it as a web server that "receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination." The server sits inside your network and holds trusted credentials, so it can reach services that an outside attacker cannot.

SSRF testing is part of [web application penetration testing](/web-application-penetration-testing/) and [API penetration testing](/api-penetration-testing/). Testing needs written authorization from the system owner.

## What is server-side request forgery?

SSRF happens when an application fetches a URL that a user supplied, or that a user can influence, and the application does not check where the URL points. OWASP's community page says the attacker "can abuse functionality on the server to read or update internal resources."

Three facts make SSRF serious:

1. The request comes from your server, so firewalls and allow-lists on internal services treat it as trusted.
2. Cloud virtual machines expose a metadata service on a link-local address that can hand out temporary credentials.
3. Many internal services have no authentication because they were never meant to face the internet.

OWASP listed SSRF as A10 in the Top 10:2021 and lists it as API7:2023 in the API Security Top 10. In the Top 10:2025 it is folded into A01 Broken Access Control.

## How does an SSRF attack work?

An SSRF attack replaces a harmless URL with a destination the server should never contact. The server sends the request, and the attacker reads the response or observes a side effect.

> **Vulnerable link preview (fictional app):** This is a demonstration. LinkPeek is a fictional app on app.example.com, and no real customer system was tested.
> 
> The app shows a preview card for any link a user pastes. The handler fetches the URL and returns the page title.
> 
> ```python
> # Vulnerable: the server fetches whatever URL the user sends
> @app.get("/api/preview")
> def preview():
>     url = request.args["url"]
>     r = requests.get(url, timeout=5)
>     return {"title": extract_title(r.text)}
> ```
> 
> Normal use:
> 
> ```http
> GET /api/preview?url=https://news.example.org/story HTTP/1.1
> Host: app.example.com
> ```
> 
> The tester points the same parameter at a listener the tester owns:
> 
> ```http
> GET /api/preview?url=https://oob.example.com/t-1042 HTTP/1.1
> Host: app.example.com
> ```
> 
> The listener log shows a request from the app server's address, with a timestamp. That log line is the evidence. A 200 response from the app proves nothing by itself.
The same pattern appears in any feature that makes the server fetch something. The next section lists where.

## Where does SSRF hide?

SSRF hides in any feature that takes a URL, hostname or file reference and makes the server load it. The table lists the common places and what to look at.

| Feature | Typical parameter | What to check |
|---|---|---|
| URL preview or link unfurling | `url`, `link` | Does the server fetch it? Is the response shown? |
| Webhooks | `callback_url`, `endpoint` | Is the target checked when saved and again when sent? |
| Import from URL | `source`, `feed` | Which schemes does the importer accept? |
| Avatar or image fetch | `avatar_url`, `image` | Does the resize library follow redirects? |
| HTML-to-PDF or screenshot service | `html`, `url` | Does the renderer load remote resources, or file paths? |
| OAuth and OIDC | `request_uri`, `jwks_uri`, client registration | Does the server fetch URLs given by a client? |
| XML parsing | external entities | See [XXE](/vulnerabilities/xml-external-entity-xxe/) |
| Proxy or "fetch" endpoints in APIs | `target`, `host` | Does the caller choose the destination? |

The OWASP API Security Top 10 notes that modern concepts such as webhooks, "login with" flows and URL previews raise the exposure, and that cloud providers' metadata services add to the impact.

## Which internal targets do attackers go after?

Attackers go after services that trust the network: cloud metadata endpoints, admin panels on loopback, databases and caches with no password, and other internal APIs. The cloud metadata services are the best documented target.

| Cloud | Address | Request rule that matters for SSRF |
|---|---|---|
| AWS (IMDSv2) | 169.254.169.254 | Needs a `PUT` to `/latest/api/token` with an `X-aws-ec2-metadata-token-ttl-seconds` header, then an `X-aws-ec2-metadata-token` header on each read |
| AWS (IMDSv1) | 169.254.169.254 | A plain `GET` works. AWS says that by default an instance accepts both versions |
| Google Cloud | metadata.google.internal or 169.254.169.254 | The request needs `Metadata-Flavor: Google`. The server denies requests without it |
| Azure | 169.254.169.254 | The documented request carries the header `Metadata:true` |

The AWS documentation says you can configure an instance to accept only IMDSv2, which makes IMDSv1 calls fail. IMDSv2 helps because an SSRF that can only send a simple `GET` cannot create the token. It does not help if the attacker controls the method and the headers, so treat it as one layer.

## Why do blocklists fail?

A blocklist fails because the same destination can be written many ways, and the check and the connection can disagree. OWASP's cheat sheet explains that blocking URLs at the application layer is hard when the application must reach any external address, and it ranks the deny-list last among defenses.

| Weakness in the filter | Why it works | Fix |
|---|---|---|
| Checks the hostname text, not the address | A public name can resolve to a private address | Resolve the name, then check the IP |
| Checks once, connects later | DNS can return a different answer on the second lookup (DNS rebinding) | Connect to the IP you checked |
| Follows redirects | A safe first URL can redirect to an internal one | Disable redirects, or re-check each hop |
| Parses the URL differently from the HTTP client | Two parsers can read the same string as two hosts | Use one parser, and compare the host the client will use |
| Alternate notations of one address | Decimal, octal, hex and IPv6 forms name the same host | Parse the address with a library, then compare |
| Allows non-HTTP schemes | `file:`, `gopher:` and `dict:` reach other services | Allow `https` and `http` only |

The PortSwigger Web Security Academy documents these filter weaknesses with labs. This page lists the classes and leaves out payloads.

## How do you fix SSRF in code?

Fix SSRF with an allow-list of destinations when you can. When users may enter any public URL, resolve the host, reject non-public addresses, connect to the address you checked, and turn off redirects. OWASP splits this into two cases: the application talks only to known partners (use an allow-list), or it must reach any external host (validate the address and add network controls).

For the first case, store partner hosts in configuration and compare the parsed host to the list. For the second case, use code like this. These examples show the pattern. Test them in your own framework and add the IPv6 and port rules your policy needs.

**Python** (standard library `ipaddress`, `requests`):

```python
import ipaddress, socket
from urllib.parse import urlparse
import requests

def safe_get(url):
    p = urlparse(url)
    if p.scheme not in ("http", "https") or not p.hostname:
        raise ValueError("bad URL")
    infos = socket.getaddrinfo(p.hostname, p.port or (443 if p.scheme == "https" else 80))
    for *_, sockaddr in infos:
        if not ipaddress.ip_address(sockaddr[0]).is_global:
            raise ValueError("blocked address")
    return requests.get(url, allow_redirects=False, timeout=5)
```

The Python documentation defines `is_global` as the opposite of `is_private`, apart from the shared address space. This sketch still resolves twice (once in the check, once in `requests`). Close that gap by connecting to the checked IP, or by running the fetcher behind an egress proxy.

**Node.js** (`net.BlockList`, `dns`):

```js
const { BlockList } = require("node:net");
const dns = require("node:dns/promises");

const blocked = new BlockList();
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addAddress("::1", "ipv6");

async function safeFetch(raw) {
  const u = new URL(raw);
  if (!["http:", "https:"].includes(u.protocol)) throw new Error("bad scheme");
  const { address, family } = await dns.lookup(u.hostname);
  if (blocked.check(address, family === 6 ? "ipv6" : "ipv4")) throw new Error("blocked");
  return fetch(raw, { redirect: "error", signal: AbortSignal.timeout(5000) });
}
```

**Java** (JDK `InetAddress`, `HttpClient`):

```java
InetAddress addr = InetAddress.getByName(uri.getHost());
if (addr.isAnyLocalAddress() || addr.isLoopbackAddress()
        || addr.isLinkLocalAddress() || addr.isSiteLocalAddress()) {
    throw new IllegalArgumentException("blocked address");
}
HttpClient client = HttpClient.newBuilder()
        .followRedirects(HttpClient.Redirect.NEVER).build();
```

**Go** (check the address at connect time, which closes the rebinding gap):

```go
dialer := &net.Dialer{
    Timeout: 5 * time.Second,
    Control: func(network, address string, c syscall.RawConn) error {
        host, _, _ := net.SplitHostPort(address)
        ip := net.ParseIP(host)
        if ip == nil || ip.IsLoopback() || ip.IsPrivate() ||
            ip.IsLinkLocalUnicast() || ip.IsUnspecified() {
            return errors.New("blocked address")
        }
        return nil
    },
}
client := &http.Client{
    Transport:     &http.Transport{DialContext: dialer.DialContext},
    CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
```

The Go version is the strongest of the four because `Control` runs on the address the socket is about to use.

## Which network and cloud controls reduce the damage?

Network controls limit what a successful SSRF can reach. Put the fetching code in its own network segment, deny its outbound traffic by default, and allow only the hosts it needs. The OWASP cheat sheet lists network-layer protection as part of the defense for the case where the application must reach any external host.

- Require IMDSv2 on every AWS instance.
- Give each workload the narrowest role. Credentials on the instance are the usual prize.
- Send outbound fetches through a proxy that applies the same address rules and logs each request.
- Do not return raw response bodies to the user. Return only the fields you need.
- Log the destination of every server-side fetch, so a probe shows up as a pattern.

## How do penetration testers detect and prove SSRF?

Testers prove SSRF with evidence that the server made the request, plus controls that show the result is not a coincidence. A 200 status on a URL parameter proves nothing, because the app may ignore the value.

Our method follows the [OWASP WSTG test for SSRF](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/07-Input_Validation_Testing/19-Testing_for_Server-Side_Request_Forgery) and the evidence standard Excession Labs uses on every engagement. We use test-owned accounts and a listener we control, we save requests and responses with timestamps, and we run controls next to every exploit request.

| Evidence type | What it shows | Label in a report |
|---|---|---|
| Callback at a tester-owned host, with time and source IP | The server made an outbound request (blind SSRF) | Observed |
| Response body from an internal address, matched to a planted marker | Full-response SSRF | Observed |
| Different response time for an open port and a closed port | The server probes internal ports | Observed, lower confidence |
| Metadata endpoint fields returned in the response | Cloud credential exposure is possible | Observed |
| Result that depends on a headless browser or renderer | The renderer fetches attacker-chosen resources | Browser-observed |

Controls we run beside each test:

- A nonexistent hostname, to see how the app reports a failed fetch.
- A URL to a public page that we own, to confirm the fetch works at all.
- The same request from an anonymous caller and a signed-in caller.
- A destination that should be blocked, to show whether the filter ever fires.

> **Sample finding (fictional):** This is a demonstration. No real customer system was tested.
> 
> Finding: SSRF in `GET /api/preview` on app.example.com. Severity: High. The server fetched a tester-owned URL (callback logged at 10:42:07 UTC from the app server's address). A request to a planted marker page on an internal test host returned the marker text in the `title` field. A control URL with a nonexistent hostname returned a different error, which shows the fetch result reaches the user. Fix: allow-list of hosts, address check at connect time, redirects off.
To keep tests safe, testers read a marker page planted on a test host and avoid reading real credentials. A cloud metadata path is tested only when the scope allows it, and the report records what was read and what was left unread.

Severity depends on what the SSRF can reach. We rate a blind SSRF that only reaches the public internet lower than a full-response SSRF that returns cloud credentials, and the report states the reason for each rating.

## Which real incidents involved SSRF?

Published records show that SSRF in widely used software can lead to serious compromise. Both cases below come from NVD records and, for the first, a government advisory.

| Date | Case | What the source says |
|---|---|---|
| 3 March 2021 | CVE-2021-26855, Microsoft Exchange Server | NVD lists it with CWE-918. CISA advisory AA21-062A says it lets an unauthenticated attacker send arbitrary HTTP requests and authenticate as the Exchange server, through the Exchange Control Panel |
| 31 March 2021 | CVE-2021-21975, VMware vRealize Operations Manager | NVD says an SSRF in the API, before version 8.4, allows a malicious actor with network access to steal administrative credentials |

NVD lists CVE-2021-26855 with a CVSS 3.1 base score of 9.1 (checked 9 November 2026). Check the NVD pages for current scores and affected versions before you quote them.

## SSRF testing checklist

1. List every feature that fetches a URL, host or file (use the table above).
2. Confirm authorization and scope, including whether cloud metadata paths are in scope.
3. Send a tester-owned callback URL and log the time and source address.
4. Compare responses for reachable, unreachable and nonexistent hosts.
5. Check which schemes and redirects the fetcher accepts.
6. Check that the target is validated when saved and again when used (webhooks).
7. Test blind cases with callbacks, and full-response cases with a planted marker.
8. Check that the fetcher cannot reach internal ranges at the network level.
9. Confirm IMDSv2 only on AWS, and check the role's permissions.
10. Record controls that held, and retest after the fix.

## When should you get a professional test?

Get a professional test when your application fetches URLs for users, runs in a cloud account with powerful roles, or sends webhooks to customer-supplied addresses. A scanner can flag a parameter named `url`. It cannot judge what your network lets the server reach. Excession Labs tests this class as part of [web application penetration testing](/web-application-penetration-testing/) and [API penetration testing](/api-penetration-testing/), under a written scope and authorization.

SSRF often combines with other flaws. [Path traversal](/vulnerabilities/path-traversal/) and [file upload vulnerabilities](/vulnerabilities/file-upload-vulnerabilities/) also turn user input into server-side file or network access, and XML parsers can trigger SSRF through [external entities](/vulnerabilities/xml-external-entity-xxe/).

## Questions

### What is the difference between SSRF and CSRF?

CSRF tricks a signed-in user's browser into sending a request to your app. SSRF tricks your server into sending a request to a destination the attacker picked. In CSRF the victim is the user. In SSRF the server is the confused party, and the attacker often reaches systems the user could never reach.

### What is blind SSRF?

In blind SSRF the server sends the request but the response never comes back to the attacker. Testers detect it with a callback to a host they control, or by timing differences between open and closed ports. The impact is usually lower than full-response SSRF, but it still proves the server can be steered.

### Does a WAF stop SSRF?

Not reliably. A WAF that matches URL patterns misses hostnames that resolve to internal addresses, redirects, and alternate address notations. OWASP says to use an allow-list and network-layer controls, and to treat a deny-list as a last resort.

### How do I fix SSRF when users must be able to enter any URL?

If you cannot use an allow-list, resolve the host yourself, reject private, loopback and link-local addresses, connect to the checked IP, disable redirects, and run the fetcher in a network segment with no route to internal services. Require IMDSv2 on AWS.

### Is SSRF still in the OWASP Top 10?

SSRF was A10:2021. In the OWASP Top 10:2025 it is rolled into A01 Broken Access Control. It is also API7:2023 in the OWASP API Security Top 10.

## Sources

- [OWASP Server-Side Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html)
- [OWASP WSTG-INPV-19: Testing for Server-Side Request Forgery](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/07-Input_Validation_Testing/19-Testing_for_Server-Side_Request_Forgery)
- [OWASP API Security Top 10 2023: API7 Server Side Request Forgery](https://owasp.org/API-Security/editions/2023/en/0xa7-server-side-request-forgery/)
- [OWASP Top 10:2021 A10 Server-Side Request Forgery](https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/)
- [PortSwigger Web Security Academy: Server-side request forgery (SSRF)](https://portswigger.net/web-security/ssrf)
- [CWE-918: Server-Side Request Forgery (SSRF)](https://cwe.mitre.org/data/definitions/918.html)
- [AWS: Use instance metadata (IMDSv1 and IMDSv2)](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html)
- [Google Cloud: Query VM metadata](https://cloud.google.com/compute/docs/metadata/querying-metadata)
- [Microsoft Learn: Azure Instance Metadata Service](https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service)
- [Python documentation: ipaddress](https://docs.python.org/3/library/ipaddress.html)
- [Node.js documentation: net.BlockList](https://nodejs.org/api/net.html)
- [NVD: CVE-2021-26855](https://nvd.nist.gov/vuln/detail/CVE-2021-26855)
- [NVD: CVE-2021-21975](https://nvd.nist.gov/vuln/detail/CVE-2021-21975)
- [CISA advisory AA21-062A: Mitigate Microsoft Exchange Server Vulnerabilities](https://www.cisa.gov/news-events/cybersecurity-advisories/aa21-062a)
