🔒 SSRF Deep Dive: Turning Your Backend Into a Network Proxy (and How to Stop It)
🕵️ Server-Side Request Forgery (SSRF)
SSRF is a trust inversion. The attacker doesn’t “hack the network” directly — they trick your server into making requests on their behalf, using your server’s credentials, network location, and implicit trust.
SSRF happens when an application fetches a remote resource (URL, IP, host, webhook, image, PDF, “import from URL”, etc.) and an attacker can influence where it fetches from.
This is especially dangerous because servers often have access to things attackers don’t:
- 🧱 Internal services (admin panels, databases, service meshes, RFC1918 networks)
- ☁️ Cloud instance metadata (temporary credentials, tokens, identity documents)
- 🔐 Privileged network paths (allowlisted destinations, private DNS zones)
- 🧩 Sensitive protocols (HTTP services without auth, internal APIs, caching layers)
🧠 SSRF in One Picture (Mental Model) ✅
Think of the application as a request relay:
- 🧑💻 Attacker controls some part of a URL (or host)
- 🖥️ Server performs a request
- 📦 Response influences the app (displayed, parsed, logged, cached, converted, screenshot, etc.)
The “relay” is the vulnerability.
📊 Types of SSRF at a Glance
| Type | What the attacker gets | Typical signals |
|---|---|---|
| 🔎 Basic SSRF | Server requests attacker-controlled host | Outbound requests to unusual domains, webhook endpoints, URL import features |
| 🕳️ Blind SSRF | Server requests happen, but attacker can’t see responses | DNS callbacks, timing differences, side-channel effects |
| 🧭 Internal SSRF | Access to internal IPs / hostnames | Requests to RFC1918 ranges, localhost, internal DNS zones |
| ☁️ Metadata SSRF | Cloud identity/credentials via metadata endpoint | Requests to link-local addresses; short-lived credential artifacts |
| 🧬 SSRF → Lateral Movement | Pivot into internal APIs / admin panels | Burst of internal HTTP calls, unusual host headers, unexpected ports |
Key insight: SSRF is not “just fetching URLs.” It’s about where your server is allowed to go and what it can reach that a user cannot.
⏱️ A Typical SSRF Kill Chain (Conceptual)
1) SSRF Primitive Found
Discovery
A feature fetches a URL (image fetcher, webhook tester, PDF renderer, link preview, import-from-URL) and accepts attacker input.
2) Reachability Probing
Exploration
Attacker infers internal reachability via response content (non-blind) or via DNS/timing side channels (blind).
3) Target Selection
Targeting
Internal admin endpoints, internal APIs, service meshes, cloud metadata endpoints, or partner allowlisted services.
4) Bypass Filtering
Evasion
Attacker tries URL parsing tricks, redirects, DNS rebinding, IPv6/IPv4 encoding, or alternate host representations.
5) Impact Realization
Abuse
Data exfiltration, credential theft (metadata), internal API actions, or chaining into RCE via vulnerable internal services.
This timeline is conceptual. In real assessments, steps may reorder or collapse. The key is that SSRF is often a pivot primitive that becomes high-impact when chained.
🧩 Where SSRF Hides in Real Apps (Red Team Lens)
SSRF most often shows up in features that “helpfully” fetch content:
- 🖼️ Image fetch/proxy: “Upload via URL”, “Fetch avatar from URL”
- 🔗 Link preview/unfurl: Slack-like previews, marketing “preview my landing page”
- 🧾 PDF/HTML conversion: render a URL to PDF, screenshot services
- 📦 Webhooks & integrations: “test webhook”, “send sample payload”
- 🧠 SSO/OIDC/SAML helpers: fetching metadata URLs, JWKS URLs
- 🧰 DevOps helpers: health checks, URL uptime monitors, internal tool dashboards
The “fetch” might be explicit (a visible URL input) or implicit (URL inside JSON, a redirect chain, a stored configuration).
🔥 Why SSRF Becomes Critical: Trust + Reach
✅ SSRF gives an attacker your server’s:
- 🌐 Network position (can it reach 10.0.0.0/8? a private VPC? localhost?)
- 🪪 Identity (service-to-service auth, mTLS, internal tokens)
- 🧷 Access policies (outbound allowlists, egress NAT, IP-based trust)
✅ SSRF often becomes “Credential Theft” in cloud
Many cloud platforms provide metadata services reachable only from the instance. If the app can be tricked into querying metadata endpoints, it may leak short-lived credentials.
Defenders: Treat outbound access to link-local metadata endpoints as a high-severity signal. SSRF + metadata is one of the most consistently high-impact SSRF outcomes.
🧱 Anatomy of a Vulnerable Pattern (Safe, Illustrative)
Many SSRF issues start with “just fetch the URL” logic, without strict validation:
// Illustrative example (do not use as-is)
import fetch from "node-fetch";
export async function fetchPreview(userUrl) {
// ❌ Dangerous: userUrl can point to internal hosts, localhost, or metadata endpoints
const res = await fetch(userUrl, { redirect: "follow" });
const text = await res.text();
return text.slice(0, 2000);
}What makes this risky?
- The server follows attacker-controlled destinations
- Redirects can silently hop from “allowed” to “internal”
- DNS can resolve to private addresses even if the hostname looks benign
- Different URL parsers disagree (edge-case parsing abuse)
🧨 SSRF Variants & What to Look For
1) 🔎 Non-blind SSRF (Response Visible)
You see the fetched content in the UI or API response.
High risk if:
- It fetches arbitrary URLs
- It follows redirects automatically
- It returns bodies/headers/statuses (useful to probe internal services)
2) 🕳️ Blind SSRF (No Response)
You can’t see the response, but you can confirm requests via:
- 🧬 DNS lookups (attacker-controlled domain receives a query)
- ⏳ Timing differences (slow internal endpoint affects response time)
- 📬 Side effects (webhook “test” causes an outbound call)
Blue team tip: DNS telemetry (resolver logs) is often your best “truth source” for blind SSRF discovery.
3) 🧭 Internal SSRF (Private Networks)
The server can reach private address space that users cannot. Defenders should assume internal services are not hardened like public ones.
4) ☁️ Metadata SSRF (Cloud)
This is where “medium” SSRF becomes “critical” SSRF: temporary credentials and instance identity are often one request away if controls are missing.
🧱 Common Filter Bypasses (Conceptual, Defensive Focus)
Many defenses fail because they validate the URL as a string, not as a resolved destination. Here are bypass families (no copy-paste exploit chains):
| Bypass family | What goes wrong | How to defend |
|---|---|---|
| 🔁 Redirect chains | Allowed URL redirects to blocked host | Validate the final destination (and every hop), restrict redirects, or disable them |
| 🧾 URL parser confusion | Different components parse host/userinfo/port differently | Use a single canonical URL parser; re-serialize; reject ambiguity |
| 🧬 DNS tricks | Hostname resolves to private IP after checks or changes over time | Resolve and pin IP before request; re-check after redirects; block private ranges |
| 🔢 Alternate IP encodings | Non-standard IP formats bypass naive regex checks | Parse as IP objects, not strings; normalize to canonical form |
| 🧷 IPv6/dual-stack | IPv6 literal or IPv4-mapped IPv6 sneaks past IPv4-only blocks | Apply private-range checks for IPv4, IPv6, and IPv4-mapped IPv6 |
| 🧪 Proxy & header smuggling | Internal proxies honor headers like Host/X-Forwarded-* unexpectedly | Don’t rely on Host for routing; harden internal proxies; strip risky headers |
The goal isn’t to memorize bypasses — it’s to design validation that is semantic (where does it actually connect?) rather than textual (what does the string look like?).
🧰 Defender-Grade Controls That Actually Work ✅
✅ 1) Prefer Allowlist Over Blocklist
If the feature only needs a small set of destinations (e.g., api.partner.com), enforce exact allowlists.
// Illustrative allowlist pattern
const ALLOWED_HOSTS = new Set([
"api.partner.example",
"cdn.partner.example",
]);
export function isAllowedHost(hostname: string) {
return ALLOWED_HOSTS.has(hostname.toLowerCase());
}Why it helps: you reduce the problem from “block the universe” to “permit only what you need.”
✅ 2) Canonicalize, Resolve, and Re-Validate (Before Connecting)
If you must accept arbitrary user URLs (e.g., link previewers), you need a multi-stage pipeline:
- Parse URL with a strict parser
- Canonicalize it (normalize)
- Resolve DNS to IPs
- Reject private/loopback/link-local ranges (IPv4 + IPv6)
- Pin the resolved IP for the connection (avoid TOCTOU)
- Re-validate on redirects (or disable redirects)
Secure default: disable redirects unless you have a strong reason to allow them.
✅ 3) Egress Network Controls (Defense-in-Depth)
Even perfect app-layer validation can fail. Backstop it with infrastructure:
- 🧱 Egress firewall rules restricting where the service can connect
- 🧩 Separate network segments for “URL fetchers”
- 🧯 Deny access to metadata endpoints at the network layer
- 🔐 Use IMDSv2 / hardened metadata access where supported (cloud-specific)
Best pattern: run URL fetchers in a dedicated sandbox service with minimal outbound access and no sensitive credentials.
✅ 4) Response Handling Hygiene
SSRF impact depends on what you do with the response:
- Don’t return raw bodies from untrusted destinations
- Avoid parsing response as XML/HTML with dangerous parsers
- Cap response sizes and timeouts
- Don’t forward internal headers back to clients
- Log and monitor destination + resolved IP + redirect chain
🔎 Detection & Monitoring (Agesec-style Playbook) 🧠
What to log (minimum viable) ✅
- 🌐 Requested URL (raw input)
- 🧭 Canonical URL (post-parse)
- 🧬 DNS answers (all resolved IPs)
- 🔁 Redirect chain (every hop)
- 📍 Final destination IP + port
- ⏱️ Timing (connect time, total time)
- 📦 Response metadata (status code, size)
What to alert on 🚨
- Requests to:
- 🏠
localhost, loopback, link-local ranges - 🧱 RFC1918 ranges (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) - ☁️ Metadata endpoints (environment-dependent)
- 🏠
- High-volume failed connections to many internal IPs (scan-like behavior)
- Redirects that change from public → private destinations
- Unexpected ports (non-80/443) from a component that “should only fetch HTTP(S)”
If you see DNS queries to attacker-controlled domains right after user actions involving URL inputs, treat it as a strong blind SSRF indicator.
🧪 “Safe SSRF Lab” Thought Experiments (No Weaponization)
Here are ways to think like a red teamer without publishing exploit chains:
- 🔍 Map where URLs are accepted (UI fields, API params, stored configs)
- 🧭 Understand network boundaries (can this service reach internal networks?)
- 🔁 Check redirect behavior (does it follow? does it re-validate?)
- 🧬 Check DNS usage (does it resolve once? cache? pin IP?)
- 🧷 Check protocol support (only HTTP(S) or also file/gopher/etc?)
- 🧾 Check parser consistency (frontend vs backend vs proxy parsing)
These questions will quickly tell you if SSRF is “possible” and whether it’s “dangerous.”
🧯 Practical Hardening Checklist ✅
| Control | Bad default ❌ | Good default ✅ |
|---|---|---|
| URL input policy | Any URL accepted | Strict allowlist or constrained patterns |
| Redirects | Follow all redirects | Disable or validate each hop + final IP |
| DNS handling | Resolve late / repeatedly | Resolve early, pin IP, re-check on redirect |
| IP filtering | Regex blocks some strings | Semantic checks over resolved IPs (IPv4+IPv6) |
| Network egress | Server can reach everything | Egress rules + dedicated fetcher sandbox |
| Metadata access | Reachable by default | Blocked by network + hardened metadata mode |
| Observability | No destination logs | Log URL + resolved IP + redirect chain + timing |
💬 Wrap-up: How to Teach Your App to Say “No” 🛡️
SSRF defenses fail when they’re:
- String-based (“block
127.0.0.1”) instead of destination-based - Implemented in one layer (app only) without egress controls
- Blind to redirects and DNS behavior
- Missing telemetry to confirm or refute “did the server call X?”
Build the control plane around: parse → canonicalize → resolve → validate → connect (pinned) → observe.