---
title: "The Request Stayed Server-Side. The Credential Did Not."
description: "SSRF severity is not the URL an application can fetch. It is the trust the outbound request inherits — network position, redirect behavior, DNS resolution, and access to metadata or control-plane identities. Test the route as a chain, not a blacklist."
date: 2026-01-29
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/posts/ssrf-control-plane-routing/
---

## SSRF in 60 seconds

An SSRF finding is usually demonstrated with a URL and reported with a sentence: "the server
can make arbitrary outbound requests." That sentence describes a capability and says almost
nothing about impact.

The server was already allowed to make requests. The security question is what those
requests inherit: internal routing, trusted source addresses, ambient credentials, proxy
exceptions, redirect handling, and access to link-local control planes. SSRF becomes serious
when an untrusted input crosses one of those boundaries and the resulting request is treated
as the server rather than as the caller.

The URL is only the first edge. The route is the finding.

## Four decisions happen after validation

Most application code validates a string, then hands it to a network stack that performs
several additional transformations:

1. The parser resolves scheme, authority, user information, host, port, and path.
2. DNS converts the accepted hostname into one or more addresses.
3. The HTTP client follows redirects, possibly to a new scheme or address.
4. A proxy, service mesh, or host route decides where the request actually goes.

If policy runs only before those transformations, it authorizes a name and the network
connects to an address. The two can disagree for the same reasons a reverse proxy and
application can disagree about a path.

A blocklist for `127.0.0.1` does not express the intended policy. The intended policy is
usually "this feature may fetch public HTTPS resources from these destinations, must not
follow a redirect outside that set, and must never reach private, loopback, link-local, or
control-plane ranges after every DNS resolution."

That is an allowlist plus a connection-time address check, not a collection of forbidden
strings.

<figure class="diagram">
<svg viewBox="0 0 700 286" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="SSRF request chain. User-controlled URL input passes application validation, DNS resolution, redirect processing, and an outbound network boundary before reaching either a public destination or an internal metadata and control-plane service. Controls are shown at every transition: canonical parsing, post-resolution address policy, redirect revalidation, egress policy, and workload identity hardening.">
<defs>
<marker id="ssrf-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-accent" /></marker>
</defs>
<text x="0" y="18" class="dg-muted">THE INPUT IS A URL — THE SECURITY DECISION IS THE RESOLVED ROUTE</text>
<rect x="0" y="34" width="126" height="68" rx="8" class="dg-box" />
<text x="14" y="56" class="dg-label">URL input</text>
<text x="14" y="76" class="dg-mono">parse once</text>
<text x="14" y="92" class="dg-muted">scheme + host</text>
<line x1="128" y1="68" x2="156" y2="68" class="dg-line-accent" marker-end="url(#ssrf-a)" />
<rect x="160" y="34" width="126" height="68" rx="8" class="dg-box" />
<text x="174" y="56" class="dg-label">DNS</text>
<text x="174" y="76" class="dg-mono">all addresses</text>
<text x="174" y="92" class="dg-muted">check results</text>
<line x1="288" y1="68" x2="316" y2="68" class="dg-line-accent" marker-end="url(#ssrf-a)" />
<rect x="320" y="34" width="126" height="68" rx="8" class="dg-box" />
<text x="334" y="56" class="dg-label">redirect</text>
<text x="334" y="76" class="dg-mono">new target</text>
<text x="334" y="92" class="dg-muted">revalidate</text>
<line x1="448" y1="68" x2="476" y2="68" class="dg-line-accent" marker-end="url(#ssrf-a)" />
<rect x="480" y="34" width="126" height="68" rx="8" class="dg-box-accent" />
<text x="494" y="56" class="dg-label">egress</text>
<text x="494" y="76" class="dg-accent">ROUTE POLICY</text>
<text x="494" y="92" class="dg-muted">last boundary</text>
<line x1="608" y1="68" x2="636" y2="68" class="dg-line-accent" marker-end="url(#ssrf-a)" />
<rect x="640" y="34" width="60" height="68" rx="8" class="dg-box-accent" />
<text x="652" y="58" class="dg-label">dest</text>
<text x="652" y="78" class="dg-crit">?</text>
<line x1="0" y1="130" x2="700" y2="130" class="dg-line dg-dash" />
<text x="0" y="156" class="dg-muted">IMPACT IS DECIDED BY WHAT THE SERVER CAN REACH AND WHO IT BECOMES</text>
<rect x="0" y="172" width="216" height="76" rx="8" class="dg-box" />
<text x="14" y="194" class="dg-accent">public canary</text>
<text x="14" y="214" class="dg-mono dg-compact">proves outbound request only</text>
<text x="14" y="232" class="dg-muted">capability evidence</text>
<rect x="242" y="172" width="216" height="76" rx="8" class="dg-box" />
<text x="256" y="194" class="dg-crit">internal service</text>
<text x="256" y="214" class="dg-mono dg-compact">crosses network trust</text>
<text x="256" y="232" class="dg-muted">boundary evidence</text>
<rect x="484" y="172" width="216" height="76" rx="8" class="dg-box-accent" />
<text x="498" y="194" class="dg-crit">metadata identity</text>
<text x="498" y="214" class="dg-mono dg-compact">request inherits workload trust</text>
<text x="498" y="232" class="dg-muted">identity-impact boundary</text>
</svg>
<figcaption>SSRF impact grows at each boundary the request crosses. A public canary proves the primitive; an internal route proves network trust; metadata or a control-plane identity proves that the server-side request can become a credentialed principal.</figcaption>
</figure>

## Start with a canary, not an internal target

The first test should answer whether the feature performs a server-side request and what that
request looks like. Use an assessment-controlled HTTPS endpoint with a unique per-request
path. Record source address, method, headers, DNS queries, redirect behavior, and timing.

```text
https://canary.example.test/ssrf/<engagement>/<request-id>
```

That observation separates server-side fetches from client-side navigation and exposes
hidden behavior without touching an internal service. A request arriving from the server's
egress address is positive evidence. No arrival is not automatically a negative result: the
application may queue work, restrict schemes, resolve through a proxy, or fetch only certain
content types. The canary should mirror the expected response shape before the test expands.

Next, change one property at a time:

- a redirect from one allowed canary hostname to a second controlled hostname;
- a hostname resolving to multiple public canary addresses;
- a slow response to identify timeout and retry behavior;
- a non-HTTPS scheme to confirm it is rejected before connection;
- an allowed hostname that redirects toward a prohibited address, which must be blocked
  without contacting that destination.

These tests map the client's state machine. They do not require probing real internal hosts.

## Validate after DNS and after every redirect

The application cannot decide whether a destination is public from the hostname string
alone. It has to resolve the name, inspect every returned address, and bind the connection
to an allowed result without letting a later lookup silently change the answer.

Redirects restart that process. A `302` is not permission to inherit the original trust
decision. The new URL needs the same scheme, host, port, DNS, and address policy as the first.
If the client changes method or forwards sensitive headers across hosts, those behaviors are
part of the finding too.

The safest fetcher is deliberately boring:

```text
parse canonical HTTPS URL
  -> compare hostname and port with explicit allowlist
  -> resolve all addresses
  -> reject private, loopback, link-local, multicast, and reserved results
  -> connect to the validated address
  -> disable redirects, or re-run the entire decision for each hop
  -> enforce response-size, content-type, and time limits
```

An outbound proxy or service-specific fetch broker makes this easier to enforce consistently.
Application code is bad at reproducing network policy in every feature that accepts a URL.

## Metadata is an identity boundary, not a special URL

On an EC2 instance, the Instance Metadata Service is available through a link-local address.
Applications sometimes treat blocking that literal address as the SSRF fix. The durable
control is stronger: require IMDSv2, constrain metadata reachability to workloads that need
it, and give the attached role only the permissions the workload requires.

IMDSv2 uses a session-oriented token. A client first sends a `PUT` to request a token and
then presents that token in subsequent metadata `GET` requests. This blocks many simple SSRF
primitives that can issue only a fixed `GET` or cannot set the required header. It does not
turn arbitrary server-side request capability into a safe design. A sufficiently capable
primitive, local proxy, or compromised process may still satisfy the exchange.

The hop limit is another boundary. It controls how far the metadata token response may
travel, which matters when container networking adds a hop. The correct value depends on the
deployment topology; setting it from a generic hardening checklist without testing the
workload can either break credential delivery or leave a broader route than intended.

For an authorised validation, use a dedicated test instance with a lab role that grants no
resource access. Prove only whether the application can reach an inert metadata path such as
instance metadata information under the configured IMDS mode. Do not retrieve or store role
credentials. The finding is stronger when it proves the boundary without creating a new
credential exposure in the evidence package.

## Egress decides whether one bug becomes a platform path

Application validation is the first control. Network egress is the independent negative
control. A workload that needs to fetch avatars from one image service should not have a
general route to every RFC1918 segment, orchestration API, metadata endpoint, and management
plane.

That separation matters because URL validation code changes frequently. Egress policy can
keep a parser regression from becoming an identity compromise. Conversely, a perfect
application allowlist should not be used as evidence that the network boundary is safe; each
control should be tested with the other assumed to fail.

## Evidence matrix

| Signal | What it proves | Negative control | Defender verification |
| --- | --- | --- | --- |
| Unique canary request arrives from server-side infrastructure | The application performs an outbound request and reveals its fetch behavior | Use an unsupported scheme or disallowed hostname and require no canary contact | Correlate application request ID, DNS, proxy, and egress logs |
| Redirect to a second controlled public host is revalidated or blocked | Redirect handling does not inherit the first destination's trust blindly | Redirect toward a prohibited test address and confirm no connection attempt leaves the host | Test every redirect hop for scheme, host, port, resolved address, method, and forwarded headers |
| All DNS answers are checked and the connection stays bound to an allowed result | Policy applies to addresses rather than only the original hostname | Return a mixed allowed/prohibited answer set and require rejection | Instrument resolver results and destination IP at connection time |
| IMDSv2-required test instance rejects a simple headerless metadata fetch | The metadata boundary resists basic GET-only SSRF | Confirm a legitimate SDK on the same workload still obtains its intended identity | Inventory `HttpTokens`, hop limit, metadata exposure, and attached role permissions |
| Egress policy denies private, link-local, and control-plane routes | A parser failure does not automatically become an internal pivot | Attempt an approved canary connection in each denied class without touching production services | Validate route tables, proxies, service-mesh policy, and host firewall from the workload identity |

## The pattern I keep seeing

The application team fixes the input string. The network team owns egress. The cloud team
owns the workload role. Each closes the piece visible from its layer, and the request path
between them remains undocumented.

That is how a finding alternates between "High" and "Low" depending on who reads it. One
person sees a URL fetcher. Another assumes metadata credentials. Neither has evidence for the
whole chain.

## What to hand the defenders

**The resolved route.** Record the canonical URL, every DNS answer, redirect hop, destination
address, proxy path, and the exact trust boundary crossed.

**Capability separated from impact.** A public canary proves server-side fetching. Internal
reachability proves a network boundary failure. Metadata or control-plane access must be
validated independently and safely before claiming identity impact.

**Two control owners.** Fix the fetcher with an allowlist and post-resolution validation;
fix the platform with egress restrictions, IMDSv2, and least-privilege workload identity.
Either control can regress. The pair is what makes the route survivable.

The request may never leave the server. The trust it carries can cross half the environment.