How to fix SSRF
SSRF (Server-Side Request Forgery) is what happens when your server makes HTTP requests to URLs supplied by users. It's the #1 way to compromise cloud accounts: SSRF → metadata service → IAM credentials → full account access. The fix is layered — allow-list + IMDSv2 + DNS rebinding protection + egress firewall.
What is SSRF?
SSRF happens when an application accepts a URL from a user and makes a server-side HTTP request to that URL. Common sources: 'fetch this URL and import the data' (RSS readers, profile-image-from-URL, webhook configurations, OAuth callbacks, link previews), 'render this URL as PDF', 'screenshot this URL', URL parameters in image processing libraries. The user controls the destination of an HTTP request initiated by your server — and that gives them access to anywhere your server can reach, including internal networks the user normally can't.
On cloud platforms, SSRF is catastrophic. AWS, GCP and Azure all expose a metadata service at a fixed local IP (169.254.169.254). Hit that IP and the metadata service returns the IAM role's temporary credentials. SSRF + metadata service = full IAM role access = potentially full cloud account compromise. This was the root cause of the 2019 Capital One breach: an SSRF in a misconfigured WAF read AWS metadata, the credentials had read access to S3, attackers exfiltrated 100M+ customer records.
Beyond cloud metadata, SSRF reaches internal services that aren't exposed to the internet — admin panels on port 8080, internal databases on the private network, Kubernetes API server, Redis instances without auth, ElasticSearch (notoriously listening on 0.0.0.0 with no auth). Even if you've correctly firewalled these from public access, your application server is INSIDE the network and can reach them — and if SSRF lets an attacker drive that server's HTTP requests, they can reach them too.
SSRF defense is layered because every single layer can be bypassed alone. Allow-listing is bypassed by DNS rebinding (the attacker's domain resolves to allowed-IP first time, attacker-IP second time). IP blocklisting misses IPv6, alternate IP encodings (decimal, octal, IPv4-mapped IPv6), and short-form (127.1). Following redirects skips the validation. The fix needs all 6 layers in this guide — none is sufficient on its own.
What an attacker can do
The concrete impact of leaving SSRF unpatched.
Cloud account compromise via metadata service
SSRF + 169.254.169.254 = AWS IAM role credentials. Same on GCP (metadata.google.internal) and Azure. Highest-impact SSRF outcome — single bug, full account.
Internal admin panel access
Internal services (admin dashboards, monitoring tools, debug endpoints) often run on localhost or private IPs without auth. SSRF reaches all of them.
Reading internal-only files
file:// URLs let SSRF read /etc/passwd, application source code, secret files. Available if your HTTP client follows the file:// protocol.
Port scanning the internal network
Time-based or response-based SSRF lets attackers enumerate which internal hosts and ports are open. Reconnaissance before targeted exploitation.
Bypassing IP-based authentication
Some internal services trust requests from specific source IPs. SSRF makes the request appear to come from your application server — which is often allow-listed.
DDoS amplification
Force your server to make thousands of HTTP requests to a target. Your IP gets blamed, your bandwidth gets consumed, your IP-rep score craters.
How do I know if I'm vulnerable?
Manual: any input that the server uses to make an HTTP request is a candidate. Look for 'webhook URL', 'avatar URL', 'fetch this URL' features. Submit URLs pointing to: http://169.254.169.254/ (AWS metadata), http://localhost/ (local services), http://127.0.0.1:6379/ (Redis), http://[::1]/ (IPv6 localhost), http://2130706433/ (decimal IP for 127.0.0.1). Check responses for any data leak. Check timing — internal IPs often respond faster than internet IPs because of network distance.
Automated: AuditCore's SSRF scanner tests URL parameters, common SSRF parameter names (url, dest, redirect, callback, source, file), and JSON body fields. It tries cloud metadata IPs (AWS/GCP/Azure), localhost variations (8 encodings of 127.0.0.1), private RFC1918 ranges, and IPv6 alternates. Free Trial covers basic SSRF on the homepage; Pro/Business runs across full crawl with multi-role auth.
Code review pattern: search for HTTP client usage where the URL is built from user input. In Node: fetch(req.body.url), axios.get(req.query.target), got(unsafeUrl). In Python: requests.get(user_url), urllib.request.urlopen(url). In Go: http.Get(url). Each is potentially SSRF unless the URL is validated through an allow-list before the call. Follow-redirects defaults vary by library — assume YES until you've checked.
How to fix SSRF
6 ordered steps. Apply them in order — each builds on the previous.
- 1
Allow-list URLs explicitly — never blocklist
Maintain an allow-list of permitted destinations. Reject everything else. Allow-listing is harder to bypass than blocklisting — there's no equivalent of 'oh, you forgot 169.254.169.254 in IPv6'.
If your feature is 'fetch a URL', who legitimately needs this? Often the answer is 'specific 3rd-party APIs you integrate with' (Stripe webhooks, S3 URLs, your own CDN). Define those exact hostnames as the allow-list. Reject everything else. Even 'allow any HTTPS URL' is dangerous if 'any URL' includes IPs and DNS names that resolve to internal addresses.
TypeScriptStep 1const ALLOWED_HOSTS = new Set([ 'api.stripe.com', 's3.amazonaws.com', 'cdn.yourdomain.com', ]); function validateUrl(rawUrl: string): URL { let url: URL; try { url = new URL(rawUrl); } catch { throw new Error('Invalid URL'); } if (url.protocol !== 'https:') throw new Error('Only HTTPS allowed'); if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('Host not allowed'); return url; } - 2
Block private/loopback/metadata IPs after DNS resolution
Allow-listing by hostname isn't enough — attacker.com can resolve to 127.0.0.1. Resolve the hostname to an IP, then verify the IP isn't in a blocked range.
Common bypass: attacker registers attacker.com pointing to 169.254.169.254 (or a CNAME chain that ends there). Your hostname allow-list doesn't catch it. Fix: resolve the hostname yourself (DNS A/AAAA lookup), check the resolved IP against private/loopback/metadata blocklists, then make the request to that exact IP (so DNS rebinding doesn't change it between resolve and connect).
Node.jsStep 2import { promises as dns } from 'dns'; import ipaddr from 'ipaddr.js'; const BLOCKED_RANGES = [ '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', // private '127.0.0.0/8', '::1/128', // loopback '169.254.0.0/16', 'fe80::/10', // link-local '0.0.0.0/8', '100.64.0.0/10', // misc ]; async function safeResolve(hostname: string): Promise<string> { const addrs = await dns.lookup(hostname, { all: true }); for (const { address } of addrs) { const parsed = ipaddr.parse(address); if (BLOCKED_RANGES.some((r) => parsed.match(ipaddr.parseCIDR(r)) )) throw new Error('Blocked IP range'); } return addrs[0].address; } - 3
Disable HTTP redirects OR re-validate every redirect target
Following redirects without re-validation defeats your URL allow-list. The first request goes to allowed.com, the redirect goes to 169.254.169.254.
HTTP clients commonly follow redirects automatically. The attacker sets up a server that returns a 302 → 169.254.169.254. Your client validates allowed.com, makes the request, then transparently follows the redirect to the metadata service. Two fixes: (a) disable redirect-following entirely and have your code handle 3xx responses manually, or (b) re-validate the redirect target through the same allow-list before following.
Node.js / undiciStep 3import { fetch } from 'undici'; // SECURE — disable redirect following entirely const res = await fetch(validatedUrl, { redirect: 'manual', // do not follow redirects automatically headers: { 'user-agent': 'YourApp/1.0' }, }); // If you need redirects, re-validate each target: let url = validatedUrl; for (let i = 0; i < 5; i++) { const res = await fetch(url, { redirect: 'manual' }); if (res.status >= 300 && res.status < 400) { const next = res.headers.get('location'); if (!next) break; url = await validateUrl(next); // reuse step 1 + 2 validation continue; } return res; // final, validated response } - 4
Enforce IMDSv2 on AWS (or migrate cloud workloads to per-pod credentials)
Cloud-specific. AWS IMDSv2 requires a session token from PUT request before the metadata service responds — SSRF (which only does GET) can't reach it.
If you run on AWS EC2/ECS/Fargate, set the instance metadata service to require IMDSv2 (HttpTokens=required). IMDSv2 forces a 2-step protocol: PUT /latest/api/token returns a session token, then GET /latest/meta-data/* with that token. Most SSRF can only do GET, breaking the chain. On Kubernetes, prefer IRSA (IAM Roles for Service Accounts) or Workload Identity (GCP) — neither is reachable via SSRF since the credential vending happens via a Kubernetes ServiceAccount, not the metadata IP.
Terraform / AWSStep 4resource "aws_launch_template" "app" { name = "app-secure-imds" metadata_options { http_endpoint = "enabled" http_tokens = "required" # IMDSv2 mandatory http_put_response_hop_limit = 1 # block container access instance_metadata_tags = "disabled" } } # Better still on EKS — use IAM Roles for Service Accounts (IRSA): # attach IAM role via OIDC; pods get credentials via projected # ServiceAccount token, not metadata IP. - 5
Add an egress firewall — your server only talks to expected destinations
Defense in depth. At the network layer, restrict outbound HTTP to ALLOWED destinations. Even if SSRF bypasses application checks, network-level egress rules block the request.
On Kubernetes, NetworkPolicy with explicit egress rules. On AWS, security group egress rules + VPC endpoints for S3/DynamoDB so traffic stays within AWS. On bare metal, iptables egress rules. For workloads that need to reach 'any third-party API', use a forward proxy (Squid, mitmproxy with allow-list) that you can audit and log. Network egress is the last line of defense — when application code fails, network rules still block.
Kubernetes / NetworkPolicyStep 5apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: webhook-worker-egress spec: podSelector: matchLabels: app: webhook-worker policyTypes: ["Egress"] egress: # Allow DNS - to: [{ namespaceSelector: { matchLabels: { name: kube-system } } }] ports: [{ protocol: UDP, port: 53 }] # Allow HTTPS to specific IP ranges (your allow-listed APIs) - to: - ipBlock: cidr: 0.0.0.0/0 except: - 169.254.169.254/32 # block AWS metadata - 10.0.0.0/8 # block internal network - 172.16.0.0/12 - 192.168.0.0/16 - 127.0.0.0/8 ports: [{ protocol: TCP, port: 443 }] - 6
Add unit + integration tests for known SSRF payloads
Test every URL-accepting endpoint against the OWASP SSRF cheatsheet payloads. Assert all are rejected.
Standard pattern: a parameterized test that submits each canonical SSRF payload to your URL handler and asserts the response is 400 (rejected) or empty (downstream returned nothing useful). Covers IPv4, IPv6, decimal/octal/hex IP encoding, DNS rebinding domains, file:// protocol, gopher://, AWS/GCP/Azure metadata. Run on every PR.
JestStep 6const SSRF_PAYLOADS = [ 'http://169.254.169.254/', // AWS metadata 'http://metadata.google.internal/', // GCP metadata 'http://localhost:6379/', // Redis 'http://127.0.0.1:8080/admin', 'http://127.1/', // short-form 'http://[::1]/', // IPv6 localhost 'http://2130706433/', // decimal 127.0.0.1 'http://0177.0.0.1/', // octal 'file:///etc/passwd', 'gopher://localhost:6379/_FLUSHALL', ]; describe.each(SSRF_PAYLOADS)('SSRF payload %s', (payload) => { it('rejects via /api/webhook/test', async () => { const res = await api.post('/api/webhook/test') .send({ url: payload }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/url|host|protocol/i); }); });
How to verify the fix
Manual: try the OWASP SSRF cheatsheet payloads against every URL-accepting endpoint. All should return 400 (rejected by validation) — never make the request, never return content. Test edge cases: redirects (set up a webhook returning 302 → 169.254.169.254), DNS rebinding (your-rebind-domain.tld with dual-A records, second resolution returns 127.0.0.1), URL parsing tricks (http://[email protected]).
Automated: AuditCore's SSRF scanner runs on Pro/Business tiers. Tests URL parameters, body fields, headers and webhook configurations. Sends 30+ SSRF payloads including cloud metadata, localhost variations, IPv6, and DNS rebinding. Reports both blocking failures (your endpoint sent the request) and information-leak findings (response timing or content reveals internal services).
Long-term: keep the unit tests in CI. On AWS, set up GuardDuty findings for instance metadata access from unusual sources — alerts on the 'something is hitting 169.254.169.254 from an unexpected pod' pattern. On Kubernetes, monitor egress NetworkPolicy denials — sudden spikes mean someone (your code or an attacker) tried to reach a blocked IP.
Frequently asked questions
Can I just block 169.254.169.254 and call it done?+
No — that's the most common bypass. Attackers use DNS rebinding (a domain that resolves to 169.254.169.254 on second lookup), short-form IPs (169.254.169.254 → 169.254.43518), IPv6 alternatives, redirect chains. Blocking the literal IP catches script-kiddie SSRF; motivated attackers route around it. Need all 6 layers in this guide.
What's DNS rebinding?+
Attacker registers evil.com with a short TTL DNS record. First lookup returns allowed-public-ip; your validator OK's it. Between validation and HTTP connect, the DNS record changes (TTL=0). Second lookup returns 127.0.0.1. Your client connects to 127.0.0.1 thinking it's allowed-public-ip. Defense: resolve hostname to IP yourself, then make the request to the IP directly (with Host header set), so the second lookup never happens.
Is gopher:// SSRF still a thing?+
Yes, on libraries that support it. Gopher lets attackers craft binary payloads — gopher://localhost:6379/_FLUSHALL%0d%0a sends raw RESP commands to a local Redis. Some HTTP clients (libcurl, Java) support gopher by default. Disable non-HTTP/HTTPS protocols explicitly in your client config (curl: --proto =https). Also affects file:// and dict://.
What about server-side rendering of arbitrary URLs (link previews)?+
Link previews are SSRF-magnets. Implement them via a separate worker pool with strict egress firewalling — never on your main API server. Use a dedicated proxy service that does URL validation, IP resolution + blocking, redirect re-validation, and timeout enforcement. Pin to IPv4-only if you don't need IPv6 (eliminates IPv6 bypass vectors). The Vercel og:image pattern (separate edge function, no internal network access) is exactly this.
How does SSRF differ from XXE?+
Both let attackers cause server-side requests, but XXE is at the XML-parser layer (entity resolution) and SSRF is at the HTTP-client layer (user-controlled URL). XXE often pivots to SSRF (entities pointing at HTTP URLs trigger HTTP requests). Fix XXE by disabling external entities (see /fix/xxe); fix SSRF by validating URLs and segmenting egress.
Should I use a forward proxy for outbound requests?+
Yes for medium-to-large apps. A forward proxy (Squid, smokescreen, mitmproxy) gives you a single audit-able point for ALL outbound traffic. Configure it with allow-list rules; route all your HTTP clients through it. Bonus: easier to audit, easier to log, easier to rate-limit. AWS NAT Gateway with VPC endpoints achieves similar segmentation at the network layer.
Does Cloudflare help against SSRF?+
Cloudflare protects YOUR site from inbound attacks; it doesn't help against SSRF (which is YOUR server making outbound requests). For outbound, you need application-level URL validation + network-level egress controls. Cloudflare's WAF can pattern-match obvious SSRF payloads in inbound traffic, but it's not the fix.
What's the AWS IMDSv1 vs IMDSv2 difference?+
IMDSv1 (legacy): GET /latest/meta-data/iam/security-credentials/<role> returns credentials with no auth. SSRF can hit it directly. IMDSv2: requires PUT /latest/api/token first to get a session token, then GET with the token. SSRF is usually GET-only — can't do the PUT. Setting HttpTokens=required on EC2 instances forces IMDSv2 only — single config change with massive SSRF mitigation.