SSRF: reproduce, bypass, and fix a preview endpoint
Server-side request forgery (SSRF) is a flaw where a server fetches a URL chosen by the caller, so the caller can reach hosts that only the server can reach. We built a small URL-preview endpoint, pointed it at a second loopback server that stands in for an internal metadata service, and read a synthetic secret back in real HTTP responses. A denylist of host strings was bypassed by a decimal IP encoding and by a redirect. A resolved-address allowlist with redirects turned off stopped both, and the legitimate preview still worked.
This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.
Define the SSRF scope and test fixture
An SSRF vulnerability exists when a server builds an outbound request from caller input and sends it from its own network position. A URL-preview feature is a common example. The caller supplies a URL, the server fetches it to show a title or thumbnail, and nothing stops the URL from pointing at an internal host.
This fixture has three roles, each a small standard-library HTTP server bound to IPv4 loopback, 127.0.0.1:
- target stands in for the public site the preview feature is meant to fetch. It serves
/article, an ordinary page, and/redirect, which answers with a 302. - metadata stands in for an internal metadata service. It serves one path,
/latest/meta-data/secret, and holds one synthetic credential string. - preview is the endpoint under test:
GET /preview?url=<url>. It fetches the given URL and reports what it found. A--modeflag selects one of three defenses:vulnerable(none),naive(block one hostname string), orstrict(resolve, check the resolved address against an allowlist, do not follow redirects).
This demonstrates the class CWE describes as CWE-918, Server-Side Request Forgery (SSRF): a web server receives a URL from an upstream component and retrieves it without adequately verifying that the request goes to the intended destination.
- Allowed requests: four GET requests to the preview endpoint, repeated across three defense modes, twelve in total.
- Addresses: the symbolic name
target.test, the literal address127.0.0.1, and the decimal IP encoding2130706433(the same loopback address written as one 32-bit integer). All three resolve to127.0.0.1; the fixture never leaves loopback. - Data: one synthetic metadata secret and one synthetic article page, both held in memory. There are no customer records, external services, or database writes.
The fixture resolves target.test from a fixed local table instead of real DNS, so the run is offline and repeatable. The literal and decimal forms go through the normal socket layer, which turns both into 127.0.0.1. The fixture always listens on loopback. For a staging engagement, agree the hosts, accounts and stopping conditions with the system owner first. The Excession Labs Acceptable Use Policy describes authorized use. This article gives no permission to test another system.
Run the downloadable fixture
Download fixture.py and verify.py into the same directory. You need Python 3.9 or later and curl; the Python code uses only the standard library. Inspect the source, then run:
python3 verify.py --output rerun
The output directory must be new. The runner starts the target and metadata servers once, then starts the preview server three times, once per mode. It sends four curl requests to each mode, saves every response and stops every server before it exits. Each curl request bypasses proxies with --noproxy '*', has a one-second connection timeout and a three-second total timeout, and does not follow redirects. Any redirect following happens inside the preview server, which is the behavior under test.
Our recorded command was python3 verify.py --output recorded-run. It exited successfully with {"checks": 12, "passed": true, "failures": []} plus the evidence path. That line means all twelve responses matched the expected outcomes, including the vulnerable and bypassed ones.
The run on 10 November 2026 used fixture and runner version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS ARM64. Exact runtime strings, UTC timestamps, commands, and source SHA-256 hashes are in results.json. A rerun will use different ports, timestamps, and response dates.
Establish positive and negative controls
Each mode received the same four requests. The first is a legitimate preview of the target article (positive control). The second asks for the metadata secret by the plain loopback address. The third asks for it by the decimal IP encoding. The fourth starts at the target site, which redirects to the metadata secret.
| Request | Vulnerable mode | Naive mode | Strict mode |
|---|---|---|---|
| Legitimate article fetch | 200, no leak | 200, no leak | 200, no leak |
Direct 127.0.0.1 metadata fetch | 200, secret leaked | 403, blocked | 403, blocked |
| Decimal-encoded metadata fetch | 200, secret leaked | 200, secret leaked | 403, blocked |
| Redirect to metadata | 200, secret leaked | 200, secret leaked | 200, no leak (302 not followed) |
The legitimate request returned 200 in every mode, so neither fix broke ordinary use. Naive mode blocked the literal address, but the secret still leaked through the two other request shapes.
Why blocking one hostname string fails
Naive mode in fixture.py rejects a request only when the parsed hostname string exactly matches one of 127.0.0.1, localhost or 0.0.0.0. It checks before it fetches anything:
if mode == "naive" and hostname in self.NAIVE_DENYLIST:
return self.reply(403, {"reason": "denylisted_host", ...})
In the recorded run, the decimal-encoded request used this exact URL:
http://2130706433:55472/latest/meta-data/secret
The string 2130706433 is not in the denylist, so the check passes. Address resolution then turns that integer into 127.0.0.1 before the connection. The recorded response returned status 200 with "secret_leaked": true. Many resolvers also accept hexadecimal (0x7f000001) and short dotted (127.1) forms, and IPv6 has its own loopback and IPv4-mapped forms. A denylist of strings misses spellings it does not list. We did not test these other forms.
The redirect case is a second failure in the same mode. The request starts at http://target.test:55473/redirect, which the denylist does not flag. The target server answers with a 302 to the metadata URL. Naive mode's HTTP client follows the redirect automatically and does not run the hostname check again. The recorded response shows status 200 with the secret present. The check runs once, before the first hop, and nothing checks later hops.
Resolve, allowlist, and stop following redirects
Strict mode makes two changes. It resolves the hostname and checks the resolved (address, port) pair against an explicit allowlist. It also turns off automatic redirect handling in the HTTP client:
resolved_ip = resolve(hostname)
if (resolved_ip, port) not in self.server.allowlist:
return self.reply(403, {"reason": "destination_not_allowlisted", ...})
opener = urllib.request.build_opener(NoRedirect)
The allowlist holds one entry, the target server's address and port. target.test on the target's port passes. The same IP on the metadata port fails, because the check uses the resolved address and port instead of the string the caller typed. With the redirect handler off, the 302 goes back to the caller and the server fetches nothing past the first hop. One gap remains. After the check, opener.open(url) resolves the hostname a second time, so the connection is not pinned to the address that was checked. A DNS name that changes its answer between the two lookups (DNS rebinding) could pass the check and connect elsewhere. The OWASP SSRF Prevention Cheat Sheet says to validate the resolved destination addresses and then make sure the HTTP client connects only to validated addresses. It also says to turn off redirect following in the client to prevent a bypass of input validation. For destinations known in advance, it recommends an allowlist, and it lists the hex, octal and dword encodings that defeat simple string checks.
The retest sent the same four requests to strict mode. The literal address and the decimal encoding both returned 403 with destination_not_allowlisted, in strict-literal.http and strict-encoded.http. The redirect request returned 200 with "upstream_status": 302 and an empty body, in strict-redirect.http. The preview endpoint reported the redirect and did not follow it. The legitimate article fetch still returned 200 with the article content.
Inspect the evidence bundle
- Machine-readable results: all twelve cases, modes, requested URLs, expected and observed outcomes, response filenames and hashes.
- Exact recorded curl commands: the ports refer to the finished run. Use the runner to create fresh servers.
- SHA-256 manifest: compare downloaded sources and evidence. This is an integrity check, not independent attestation.
- Fixture instructions and limitations, server source, and verification source.
The metadata value and the article are synthetic demo data. The bundle includes the raw HTTP response text, with dates and server headers. No production credentials or customer data were involved.
What this experiment does not cover
Twelve requests against three toy servers show one SSRF path and two bypasses of one naive defense. We did not test DNS rebinding (which the strict mode does not block, as noted above), hex, octal, IPv6 or IPv4-mapped forms, cloud metadata quirks, POST-based or webhook-style SSRF, out-of-band confirmation of blind SSRF, or production network segmentation. A fixture confined to loopback cannot show what a real internal boundary would stop. It shows that a check on the host string fails where a check on the resolved address holds, for these four requests.
For a real engagement, list every feature that accepts a URL, a hostname or anything used to build one: webhooks, importers, PDF or image renderers, link previews and SSO metadata URLs. Test each one with encoded addresses and with a redirect chain. The redirect bypass here overlaps with open redirect testing. A server that trusts an attacker-controlled Host header has a related failure, covered in host header injection. Authorization checks on the object itself are covered in authorization testing for APIs. Keep failed and inconclusive attempts with the successful ones, and rerun the same cases after each change to the fetching code.
Excession Labs tests outbound-request handling like this in its security assessments. See how a finding like this looks in a sample report. Download this finding written up as a one-page sample report (PDF).