๐Ÿ›ก OWASP Lab
API7:2023 High

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