10 min read

Advanced

SSRF Remediation

Risk Severity
Critical
Fix Effort
High (Significant Work)
Est. Time
4-8 hours
Reference
A01:2025 CWE-918

Server-Side Request Forgery (SSRF) allows attackers to make requests from your server to internal resources, cloud metadata endpoints, or external systems. This can lead to data exfiltration, internal service access, and cloud credential theft.

SSRF Attack Surface

Understanding SSRF

High-Risk Targets

  • • 169.254.169.254 - Cloud metadata (AWS, GCP, Azure)
  • • localhost/127.0.0.1 - Internal services
  • • 10.x.x.x, 192.168.x.x - Private networks
  • • file:// - Local file access
  • • Internal APIs and admin panels

Common Vulnerable Features

  • • URL/image preview generators
  • • Webhook integrations
  • • File importers (URL-based)
  • • PDF generators
  • • Proxy/redirect functionality

URL Validation

Allowlist Over Blocklist

Blocklists are easily bypassed (DNS rebinding, URL encoding, alternative representations). Always use allowlists when possible.

Allowlist Approach (Recommended)

import urllib.parse
from ipaddress import ip_address, ip_network

ALLOWED_DOMAINS = {'api.example.com', 'cdn.example.com', 'images.example.com'}
ALLOWED_SCHEMES = {'https'}

def is_url_allowed(url: str) -> bool:
    """Validate URL against allowlist"""
    try:
        parsed = urllib.parse.urlparse(url)
        
        # Check scheme
        if parsed.scheme not in ALLOWED_SCHEMES:
            return False
        
        # Check domain against allowlist
        if parsed.hostname not in ALLOWED_DOMAINS:
            return False
        
        # Prevent port manipulation
        if parsed.port and parsed.port not in [443, 80]:
            return False
            
        return True
    except Exception:
        return False

# Usage
url = request.form.get('url')
if not is_url_allowed(url):
    abort(400, 'Invalid URL')
    
response = requests.get(url, timeout=5)

Block Private/Reserved IPs

import socket
from ipaddress import ip_address, ip_network

# Define private/reserved ranges
BLOCKED_NETWORKS = [
    ip_network('127.0.0.0/8'),      # Loopback
    ip_network('10.0.0.0/8'),       # Private
    ip_network('172.16.0.0/12'),    # Private
    ip_network('192.168.0.0/16'),   # Private
    ip_network('169.254.0.0/16'),   # Link-local (cloud metadata!)
    ip_network('0.0.0.0/8'),        # Current network
    ip_network('100.64.0.0/10'),    # Shared address space
    ip_network('192.0.0.0/24'),     # IETF protocol assignments
    ip_network('192.0.2.0/24'),     # TEST-NET-1
    ip_network('198.51.100.0/24'),  # TEST-NET-2
    ip_network('203.0.113.0/24'),   # TEST-NET-3
    ip_network('fc00::/7'),         # IPv6 unique local
    ip_network('fe80::/10'),        # IPv6 link-local
    ip_network('::1/128'),          # IPv6 loopback
]

def is_ip_allowed(ip_str: str) -> bool:
    """Check if IP is not in blocked ranges"""
    try:
        ip = ip_address(ip_str)
        for network in BLOCKED_NETWORKS:
            if ip in network:
                return False
        return True
    except ValueError:
        return False

def resolve_and_validate(hostname: str) -> str:
    """Resolve hostname and validate IP"""
    try:
        ip = socket.gethostbyname(hostname)
        if not is_ip_allowed(ip):
            raise ValueError(f"Blocked IP: {ip}")
        return ip
    except socket.gaierror:
        raise ValueError("DNS resolution failed")

DNS Rebinding Protection

Attackers can bypass IP validation using DNS rebinding - where a hostname resolves to an allowed IP initially, then changes to an internal IP on subsequent requests.

import requests
from requests.adapters import HTTPAdapter

class SSRFSafeAdapter(HTTPAdapter):
    """HTTP adapter that validates IPs before connection"""
    
    def send(self, request, **kwargs):
        # Parse hostname from request
        from urllib.parse import urlparse
        parsed = urlparse(request.url)
        hostname = parsed.hostname
        
        # Resolve and validate before each request
        ip = socket.gethostbyname(hostname)
        if not is_ip_allowed(ip):
            raise ValueError(f"Blocked IP: {ip}")
        
        return super().send(request, **kwargs)

# Use custom session
session = requests.Session()
session.mount('http://', SSRFSafeAdapter())
session.mount('https://', SSRFSafeAdapter())

# Make request - IP validated at connection time
response = session.get(url, timeout=5)

Cloud Metadata Protection

Critical for Cloud Environments

SSRF to cloud metadata endpoints (169.254.169.254) is one of the most dangerous attack vectors, allowing credential theft and privilege escalation.

AWS IMDSv2 (Required Headers)

# AWS - Require IMDSv2 (instance metadata service v2)
# This requires a PUT request with hop limit, blocking SSRF via GET

# Via AWS CLI
aws ec2 modify-instance-metadata-options \
    --instance-id i-1234567890abcdef0 \
    --http-tokens required \
    --http-put-response-hop-limit 1

# Terraform
resource "aws_instance" "example" {
  # ... other config
  
  metadata_options {
    http_tokens                 = "required"  # Enforce IMDSv2
    http_put_response_hop_limit = 1
    http_endpoint               = "enabled"
  }
}

Network-Level Blocking

# iptables - Block metadata endpoint from application
iptables -A OUTPUT -d 169.254.169.254 -j DROP

# AWS Security Group - Block outbound to metadata (not always possible)
# Better: Use IMDSv2 and validate at application level

# Docker - Block from containers
docker network create --driver bridge \
    --opt com.docker.network.bridge.enable_ip_masquerade=true \
    --opt com.docker.network.bridge.host_binding_ipv4=0.0.0.0 \
    no-metadata-network

# Kubernetes NetworkPolicy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: block-metadata
spec:
  podSelector: {}
  policyTypes:
  - Egress
  egress:
  - to:
    - ipBlock:
        cidr: 0.0.0.0/0
        except:
        - 169.254.169.254/32

Secure Architecture

✅ Do

  • • Use allowlist for permitted domains/IPs
  • • Validate resolved IPs, not just hostnames
  • • Implement request timeouts
  • • Disable unnecessary URL schemes
  • • Use separate service accounts with limited permissions
  • • Enable IMDSv2 on all cloud instances

❌ Don't

  • • Rely solely on blocklists
  • • Trust user-supplied URLs without validation
  • • Allow arbitrary URL redirects
  • • Expose internal error messages
  • • Run with overly permissive IAM roles
  • • Allow non-HTTP schemes (file://, gopher://)

Safe Response Handling

def fetch_url_safely(url: str, max_size: int = 1024 * 1024) -> bytes:
    """Fetch URL with safety controls"""
    
    # Validate URL first
    if not is_url_allowed(url):
        raise ValueError("URL not allowed")
    
    # Resolve and validate IP
    from urllib.parse import urlparse
    parsed = urlparse(url)
    resolve_and_validate(parsed.hostname)
    
    # Make request with safety controls
    response = requests.get(
        url,
        timeout=5,                    # Connection timeout
        allow_redirects=False,        # Don't follow redirects (or validate each)
        stream=True                   # Don't load entire response to memory
    )
    
    # Check content length
    content_length = response.headers.get('Content-Length')
    if content_length and int(content_length) > max_size:
        raise ValueError("Response too large")
    
    # Read with size limit
    content = b''
    for chunk in response.iter_content(chunk_size=8192):
        content += chunk
        if len(content) > max_size:
            raise ValueError("Response too large")
    
    return content

Testing Verification

SSRF Test Payloads

# Cloud metadata endpoints - should be BLOCKED
http://169.254.169.254/latest/meta-data/
http://metadata.google.internal/computeMetadata/v1/
http://169.254.169.254/metadata/instance

# Internal network - should be BLOCKED
http://127.0.0.1:80
http://localhost:22
http://192.168.1.1
http://10.0.0.1

# Bypass attempts - should also be BLOCKED
http://127.0.0.1.nip.io
http://0x7f000001
http://2130706433
http://[::1]

# DNS rebinding test - use services like rebind.network

Expected Behavior

Allowed URLs

Should succeed - external, allowlisted domains

Blocked URLs

Should return error without making request

Common Mistakes

❌ Blocklist-Only Validation

# DON'T: Blocklist is easy to bypass
blocked = ['127.0.0.1', 'localhost']
if host not in blocked:
    fetch(url)  # Bypassed with 127.0.0.2

Hundreds of bypass techniques exist

✅ Correct Approach

# DO: Use strict allowlist
allowed = ['api.trusted.com']
if host in allowed:
    fetch(url)

Only explicitly allowed hosts can be accessed

❌ URL Validation Before DNS

# DON'T: DNS rebinding possible
if not is_private(parsed.hostname):
    fetch(url)  # Hostname can resolve 
                # to 127.0.0.1 after check

DNS can change between check and use

✅ Correct Approach

# DO: Validate resolved IP, pin DNS
ip = socket.gethostbyname(hostname)
if not is_private(ip):
    # Use resolved IP directly
    fetch_by_ip(ip, hostname)

Check actual IP that will be connected to

Verification Checklist

  • ☐ URL scheme restricted to https (or http if required)
  • ☐ Hostname validated against allowlist
  • ☐ Resolved IPs checked against blocked ranges
  • ☐ DNS rebinding protection implemented
  • ☐ Cloud metadata endpoint (169.254.169.254) blocked
  • ☐ IMDSv2 enforced on all cloud instances
  • ☐ Redirect following disabled or validated
  • ☐ Response size limited
  • ☐ Timeouts configured
  • ☐ Error messages don't leak internal info

SSRF Security Labs

Practice exploiting and defending against SSRF vulnerabilities.

SSRF Attacks PortSwigger hard
Basic SSRFBlind SSRFSSRF with filter bypass

Evidence to Capture

The request: The full request carrying the URL, with the JSON key or form field identified, and the response. Note whether the fetched body is returned to you or only used server-side.

Internal reach: The response body from an address only the server can reach — 169.254.169.254/latest/meta-data/, http://127.0.0.1:8500/v1/status, an internal admin page — with the status code and a short excerpt.

Out-of-band interaction: The DNS or HTTP callback on a listener you control, with source address and timestamp. This is the only proof available when the response is not reflected.

Redirect chain: Each hop of the 302 that turned an allowlisted host into an internal target, captured in order, because a single-hop allowlist check often passes the first request and fails the second.

Credential exposure: What the metadata or internal service returned: role name, token prefix, or the unauthenticated internal action performed. Redact the secret but keep enough to size the impact.

Likely False Positives

  • Client-side redirect: If the app returns a URL and the browser fetches it, no server-side request occurred. Check the response body, not the rendered page.
  • Blocked by egress policy: A timeout after successful DNS resolution means a control is working at the network layer. Report it as residual risk only if some internal path did respond.
  • Your own proxy answering: A callback from your test host after a --resolve or proxy misconfiguration is not the target fetching. Compare the source address with the target egress range.
  • Allowlisted proxy that rejected the target: A generic error plus an exact-host allowlist is the fix. Confirm by pointing the parameter at a host you control on the allowlist and watching for a hit.
  • Rebinding claim without a second resolution: DNS rebinding requires proof that the second resolution was used by the request. One A record change is not enough.

Testing Checklist

Discovery

  • ☐ Find every server-side fetch: URL preview, avatar or image proxy, webhook delivery, feed and file importer, PDF or HTML renderer, SSO metadata fetch.
  • ☐ Note the scheme and host controls visible in error messages, and whether the URL is validated before or after DNS resolution.
  • ☐ Identify the egress context: which network the server fetches from and what it can reach there.

Confirmation

  • ☐ Try the internal target by IP, by encoded forms (2130706433, 0177.0.0.1), and by a hostname you control resolving to a private address.
  • ☐ Route the request through an open redirect on an allowlisted domain and check whether the second hop is revalidated.
  • ☐ Use an out-of-band listener whenever the response is not returned, and confirm the source address belongs to the target.

Impact

  • ☐ Prove credential access by reading the instance role from the metadata service, or prove an unauthenticated action on an internal service.
  • ☐ State whether IMDSv2 is required and what hop limit is set, because 169.254.169.254 is only half the story.
  • ☐ Record the reachable internal surface — other tenants, admin panels, databases — that the fetch context exposes.

Remediation

Structural fix

  • • Allowlist exact hosts and schemes, resolve the name yourself, validate the resulting address against private, loopback and link-local ranges, then connect to that resolved address so the check and the connection agree.
  • • Disable redirects on the fetch, or follow them manually with each hop revalidated, and refuse non-HTTP schemes outright.
  • • Where the destination is a known set, replace the URL parameter with an identifier the server maps to a stored destination, so no user-supplied URL reaches the client library.

Compensating control

  • • Force all outbound fetches through an egress proxy that holds the allowlist, and drop direct egress from the application subnet.
  • • Require IMDSv2 with a hop limit of one, use a scoped instance role, and block 169.254.0.0/16 at the network policy.
  • • Cap response size and timeout, so a reachable internal endpoint cannot be used to exfiltrate at will.

CWE References: CWE-918