Server-Side Request Forgery (SSRF) Prevention

Server-Side Request Forgery occurs when an application fetches a remote resource without adequately validating the user-supplied URL, turning the server into an unwitting proxy for attacker-controlled requests. In cloud-native architectures, SSRF transforms from a theoretical vulnerability into a critical infrastructure compromise vector: attackers leverage it to access internal microservices, bypass network segmentation, and exfiltrate cloud metadata endpoints carrying IAM credentials. This guide is part of the Vulnerability Patterns & Web Mitigation Strategies reference, and sits alongside related controls for Cross-Site Request Forgery (CSRF) defense and injection attack prevention. Effective mitigation requires defense-in-depth spanning application-layer validation, network egress controls, and runtime enforcement — each layer addressed in depth below.


Threat Anatomy

SSRF manifests in two primary forms. Full SSRF returns the fetched resource directly to the attacker, exposing internal service responses verbatim. Blind SSRF reveals success through out-of-band channels — DNS lookups, HTTP callbacks to attacker-controlled servers, or response timing differences — making it harder to detect but equally dangerous.

Modern exploitation frequently abuses alternative protocol handlers. Where HTTP/HTTPS are blocked, attackers pivot to gopher://, dict://, file://, and ftp:// to interact with internal services or trigger memory-corruption in legacy protocol parsers. Redirect chains compound the risk: an initial https:// request that the application permits may follow a 30x redirect to an internal http:// address, bypassing scheme validation entirely.

MITRE ATT&CK mapping: SSRF maps to technique T1190 (Exploit Public-Facing Application) for initial access and T1552.005 (Cloud Instance Metadata API) for credential access. Cloud SSRF also enables T1078.004 (Valid Accounts: Cloud Accounts) when metadata tokens are harvested and replayed.

Cloud Metadata Exploitation

Cloud environments amplify SSRF impact through Instance Metadata Services (IMDS). The link-local endpoint 169.254.169.254 exposes temporary IAM tokens, user data, SSH keys, and internal network topology. A single successful SSRF request to /latest/meta-data/iam/security-credentials/<role> returns a set of temporary AWS credentials valid for hours. The attacker gains lateral movement and privilege escalation without ever touching the credential store.

GCP and Azure have equivalent metadata paths (metadata.google.internal, 169.254.169.254/metadata/instance). The SSRF allowlists and metadata service protection deep-dive covers per-provider hardening including IMDSv2 enforcement.

Unlike client-side request manipulation (covered in CSRF defense), SSRF exploits server-side trust boundaries. The server acts as a privileged proxy, inheriting its network position inside the perimeter. Applying the STRIDE framework to outbound request handlers surfaces the following threats:

STRIDE Category Threat Example
Information Disclosure Metadata token exfiltration GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
Elevation of Privilege Internal admin panel access GET http://10.0.0.1:8080/admin/ via SSRF
Spoofing Impersonating internal services Forging source IP via SSRF chaining
Denial of Service Slow-loris against internal endpoints Targeting slow internal HTTP services to exhaust thread pool

Prerequisites & Scope

Before applying these controls, confirm the following are in place:

  • The application accepts user-supplied or attacker-influenced URLs, file paths, or hostnames (webhook endpoints, image import URLs, PDF generation services, URL preview fetchers, webhook delivery systems).
  • Backend services make outbound HTTP/HTTPS calls on behalf of users, even indirectly (e.g., fetching Open Graph metadata from a user-supplied link).
  • Cloud-hosted workloads that run on AWS, GCP, or Azure where the IMDS is reachable from compute instances.
  • Microservice environments where inter-service calls traverse the same network segment as external-facing request handlers.
  • Infrastructure controlled by the team (or clearly documented if using a managed egress proxy), so network-layer controls can be enforced.

The controls here apply to any language or framework that makes outbound HTTP calls. Code examples use Python, TypeScript, and Go — the primary stacks for backend web services. Kubernetes-specific controls (Istio EgressGateway, NetworkPolicy) are highlighted where they apply.


Mitigation Architecture

The diagram below shows an SSRF-hardened request flow. Every outbound call passes through three validation checkpoints: scheme/URL parsing, pre-flight DNS resolution with IP validation, and network-layer egress filtering. A request is only forwarded if it clears all three.

SSRF-Hardened Request Architecture Diagram showing three validation checkpoints for outbound HTTP requests: URL parser and scheme allowlist, pre-flight DNS resolver with IP blocklist check, and network egress proxy with CIDR filter. A blocked path leads to a 403 error response; a permitted path proceeds to the external service. User Input Untrusted URL ① URL Parser Scheme allowlist http / https only No credentials in URL ② Pre-flight DNS Resolve hostname → IP Block RFC 1918 ranges Block 169.254.0.0/16 Block ::1, fc00::/7 Pin resolved IP ③ Egress Proxy CIDR allowlist enforced Domain allowlist TLS required BLOCKED 403 + audit log External PASS PASS PASS FAIL Untrusted / Blocked Validation checkpoint Permitted outbound

The three-checkpoint model is deliberately redundant. An attacker who defeats one layer (for example, by crafting a URL that slips through a naive regex parser) still faces DNS pre-resolution at the second layer, then network egress filtering at the third. No single bypass defeats all three simultaneously.


Step-by-Step Implementation

Step 1 — Strict URL Parsing and Scheme Allowlisting (OWASP ASVS 5.2.6 / NIST SP 800-53 SI-10)

Use the standard library URL parser for the runtime — never write a custom regex to parse URLs. Reject any request whose scheme is not explicitly http or https. Reject embedded credentials (user:pass@host), fragments, and non-standard ports unless explicitly required.

import { URL } from 'url';

const ALLOWED_SCHEMES = new Set(['http:', 'https:']);

export function parseAndValidateURL(raw: string): URL {
  let parsed: URL;
  try {
    parsed = new URL(raw);
  } catch {
    throw new Error('Invalid URL: cannot be parsed');
  }

  if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
    throw new Error(`Unsupported scheme: ${parsed.protocol}`);
  }

  // Reject embedded credentials — these are unused by modern servers
  // but can confuse parsers and enable bypass via user@host tricks
  if (parsed.username || parsed.password) {
    throw new Error('URLs with embedded credentials are not permitted');
  }

  return parsed;
}

Step 2 — Pre-flight DNS Resolution and IP Blocklisting (OWASP ASVS 5.2.7 / NIST SP 800-53 SC-7)

After parsing, resolve the hostname to its IP address before opening any connection. Validate the resolved IP against a comprehensive blocklist covering all private, loopback, link-local, and multicast ranges in both IPv4 and IPv6.

import ipaddress
import socket
import urllib.parse
from typing import Optional

BLOCKED_NETWORKS = [
    # RFC 1918 private ranges
    ipaddress.ip_network('10.0.0.0/8'),
    ipaddress.ip_network('172.16.0.0/12'),
    ipaddress.ip_network('192.168.0.0/16'),
    # Loopback
    ipaddress.ip_network('127.0.0.0/8'),
    ipaddress.ip_network('::1/128'),
    # Link-local / cloud metadata
    ipaddress.ip_network('169.254.0.0/16'),
    ipaddress.ip_network('fe80::/10'),
    # "This" network
    ipaddress.ip_network('0.0.0.0/8'),
    # Unique local (ULA)
    ipaddress.ip_network('fc00::/7'),
    # Multicast
    ipaddress.ip_network('224.0.0.0/4'),
    ipaddress.ip_network('ff00::/8'),
]

def resolve_and_validate(hostname: str) -> str:
    """Resolve hostname and return the validated IP string, or raise."""
    try:
        # getaddrinfo returns all address families; take the first result
        addrinfo = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
    except socket.gaierror as exc:
        raise ValueError(f'DNS resolution failed for {hostname!r}: {exc}') from exc

    for family, _, _, _, sockaddr in addrinfo:
        ip_str = sockaddr[0]
        try:
            ip_obj = ipaddress.ip_address(ip_str)
        except ValueError:
            raise ValueError(f'Could not parse resolved address: {ip_str!r}')

        # Normalise IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1)
        if isinstance(ip_obj, ipaddress.IPv6Address) and ip_obj.ipv4_mapped:
            ip_obj = ip_obj.ipv4_mapped

        for network in BLOCKED_NETWORKS:
            if ip_obj in network:
                raise ValueError(
                    f'Resolved IP {ip_str!r} for {hostname!r} is in blocked range {network}'
                )

    # Return the first successfully validated IP for connection pinning
    return addrinfo[0][4][0]


def secure_fetch(url: str, timeout: tuple[float, float] = (3.0, 5.0)):
    import requests

    parsed = urllib.parse.urlparse(url)
    if parsed.scheme not in ('http', 'https'):
        raise ValueError('Only http/https schemes are allowed')

    resolve_and_validate(parsed.hostname)

    # disable_redirects: a 301 to an internal host is a classic SSRF bypass
    return requests.get(url, timeout=timeout, allow_redirects=False)

Step 3 — DNS Rebinding Mitigation via Connection Pinning (OWASP ASVS 5.2.8)

Standard pre-flight resolution has a Time-of-Check to Time-of-Use (TOCTOU) gap: an attacker-controlled DNS record can return a public IP during validation, then switch to an internal IP before the TCP connection opens. Pin the resolved IP by connecting directly to the IP address while preserving the Host header for TLS SNI and virtual hosting.

In Go, override DialContext to intercept the actual connection and verify the IP a second time at connect time:

package main

import (
    "context"
    "fmt"
    "net"
    "net/http"
    "time"
)

var blockedCIDRs = []string{
    "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
    "127.0.0.0/8", "169.254.0.0/16", "0.0.0.0/8",
    "::1/128", "fc00::/7", "fe80::/10",
}

var parsedCIDRs []*net.IPNet

func init() {
    for _, cidr := range blockedCIDRs {
        _, ipNet, _ := net.ParseCIDR(cidr)
        parsedCIDRs = append(parsedCIDRs, ipNet)
    }
}

func isBlocked(ip net.IP) bool {
    // Normalise IPv4-in-IPv6 representation
    if v4 := ip.To4(); v4 != nil {
        ip = v4
    }
    for _, cidr := range parsedCIDRs {
        if cidr.Contains(ip) {
            return true
        }
    }
    return false
}

// secureDialContext resolves the hostname itself and validates the IP
// before handing the pinned address to the dialer, eliminating the
// TOCTOU window between pre-flight validation and actual TCP connect.
func secureDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
    host, port, err := net.SplitHostPort(addr)
    if err != nil {
        return nil, fmt.Errorf("invalid address %q: %w", addr, err)
    }

    ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
    if err != nil {
        return nil, fmt.Errorf("dns lookup failed for %q: %w", host, err)
    }

    for _, ipAddr := range ips {
        if isBlocked(ipAddr.IP) {
            return nil, fmt.Errorf("resolved IP %s for %q is in a blocked range", ipAddr.IP, host)
        }
    }

    // Connect directly to the resolved IP, pinning the address
    pinned := net.JoinHostPort(ips[0].IP.String(), port)
    d := &net.Dialer{Timeout: 3 * time.Second}
    return d.DialContext(ctx, network, pinned)
}

func NewSecureHTTPClient() *http.Client {
    return &http.Client{
        Timeout: 10 * time.Second,
        Transport: &http.Transport{
            DialContext: secureDialContext,
        },
        // Prevent redirect-based SSRF: a 301 to an internal address bypasses
        // the pre-flight check on the original URL
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            return http.ErrUseLastResponse
        },
    }
}

Step 4 — Network-Layer Egress Filtering (NIST SP 800-53 SC-7 / SOC 2 CC6.6)

Application-layer validation is necessary but not sufficient. Add a second enforcement point at the network layer so that a validation bypass in application code still cannot reach internal addresses.

Kubernetes NetworkPolicy — default-deny egress with explicit external-only permit:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-internal-egress
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: webhook-fetcher
  policyTypes:
    - Egress
  egress:
    # Allow DNS
    - ports:
        - protocol: UDP
          port: 53
    # Allow outbound HTTPS to public internet only
    # (non-RFC-1918, non-link-local)
    - ports:
        - protocol: TCP
          port: 443
        - protocol: TCP
          port: 80
      # No ipBlock.cidr here means all destinations are permitted
      # by this rule; the absence of RFC 1918 blocks is enforced
      # by the explicit deny rules below via a dedicated egress gateway.

Istio ServiceEntry + Sidecar — explicit external domain allowlist:

apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
  name: allowed-external-apis
spec:
  hosts:
    - "api.stripe.com"
    - "hooks.slack.com"
  ports:
    - number: 443
      name: https
      protocol: HTTPS
  location: MESH_EXTERNAL
  resolution: DNS
---
apiVersion: networking.istio.io/v1beta1
kind: Sidecar
metadata:
  name: webhook-fetcher-sidecar
spec:
  workloadSelector:
    labels:
      app: webhook-fetcher
  egress:
    - hosts:
        - "./*"            # same namespace services
        - "istio-system/*" # telemetry
        - "~/*"            # only the ServiceEntry hosts above for external

Step 5 — Harden Cloud Metadata Endpoints (AWS IMDSv2)

Require IMDSv2 on every EC2 instance, which mandates a session-oriented token header and a maximum hop limit of 1, making it unreachable via SSRF from an application running in the same instance:

# AWS CloudFormation snippet — enforce IMDSv2 at launch
LaunchTemplate:
  Properties:
    MetadataOptions:
      HttpTokens: required          # IMDSv2 only; "optional" allows v1 fallback
      HttpPutResponseHopLimit: 1    # Prevents containers from reaching the host IMDS
      HttpEndpoint: enabled
# Enforce IMDSv2 on a running instance via AWS CLI
aws ec2 modify-instance-metadata-options \
  --instance-id i-0abc1234567890def \
  --http-tokens required \
  --http-put-response-hop-limit 1

For detailed per-provider allowlist configuration and GCP/Azure metadata hardening, see SSRF allowlists and metadata service protection.


Edge Cases & Bypass Patterns

DNS Rebinding (TOCTOU)

The classic bypass: attacker registers evil.example.com with a short TTL. During pre-flight validation it resolves to a public IP; the validator passes. Before the TCP connection opens, the attacker changes the DNS record to 192.168.1.1. The connection reaches an internal host.

Fix: Use the pinned-IP dialer pattern from Step 3. Alternatively, route all outbound traffic through a proxy that resolves DNS once and locks the connection to that IP.

IPv6 and IPv4-Mapped Address Bypass

A validator that only checks the IPv4 blocklist passes ::ffff:127.0.0.1 (IPv4-mapped IPv6 loopback), 0::ffff:10.0.0.1 (mapped private), or ::1 (IPv6 loopback). The OS resolves these to internal addresses regardless.

Fix: After resolving an IPv6 address, call .ipv4_mapped in Python or ip.To4() in Go to normalise it before blocklist comparison, as shown in Steps 2 and 3 above.

Redirect Chains

An allowlisted URL returns a 301 Location: http://169.254.169.254/latest/meta-data/. HTTP clients follow redirects by default, making the redirect target effectively bypassing the pre-flight check on the original URL.

Fix: Disable automatic redirect following (allow_redirects=False in Python requests, CheckRedirect: func(...) error { return http.ErrUseLastResponse } in Go). If redirects are required by the use case, re-validate the Location header URL through the full validation pipeline before following it.

Protocol Smuggling via Scheme Override

Some URL parsers accept HTTPS://, Http://, or percent-encoded schemes (ht%74ps://). A validator checking parsed.scheme == 'https' on the raw string may be fooled.

Fix: Lower-case and normalise the URL through a standards-compliant parser (Node.js URL, Python urllib.parse, Go net/url) before any comparison. These parsers normalise scheme case by specification.

Open Redirect Chaining (Cross-Origin Forwarding)

An application with an open redirect vulnerability (/redirect?to=<url>) can be chained with SSRF: the SSRF target is the redirect endpoint, which forwards to an internal address. The pre-flight validator sees a same-origin URL and passes.

Fix: Eliminate open redirects (covered under secure HTTP header configuration). Apply the same URL validation on the final destination of any redirect, not only the initial input. Enforcing trust boundaries across internal redirect flows is equally important.

URL Fragment and Query-String Confusion

Parser inconsistencies between the application and underlying HTTP library can lead to different URL interpretations. A URL like https://[email protected]/path passes a validator checking only the hostname field of a naive parser, but some libraries resolve it as a request to 192.168.1.1 with Host: public.example.com.

Fix: Reject any URL containing @ in the authority component. Validate both parsed.hostname and the raw authority for unexpected characters.


Automated Testing & CI Validation

Unit Tests for the Secure Fetch Wrapper

import pytest
from your_module import secure_fetch, resolve_and_validate

BLOCKED_URLS = [
    "http://169.254.169.254/latest/meta-data/",
    "http://127.0.0.1/admin",
    "http://10.0.0.1:8080/internal",
    "http://192.168.1.100/secrets",
    "gopher://internal-host:70/payload",
    "file:///etc/passwd",
    "https://[::1]/",
]

@pytest.mark.parametrize("url", BLOCKED_URLS)
def test_blocked_url_raises(url: str):
    with pytest.raises(ValueError):
        secure_fetch(url)


def test_ipv4_mapped_ipv6_blocked():
    """::ffff:127.0.0.1 must be normalised and blocked."""
    with pytest.raises(ValueError, match="blocked range"):
        resolve_and_validate("0000:0000:0000:0000:0000:ffff:7f00:0001")


def test_redirect_not_followed(requests_mock):
    """Ensure 301 to internal address is not followed."""
    requests_mock.get(
        "https://public.example.com/path",
        status_code=301,
        headers={"Location": "http://192.168.1.100/secret"},
    )
    response = secure_fetch("https://public.example.com/path")
    assert response.status_code == 301
    # Must NOT have fetched the redirect target
    assert "secret" not in response.text

CI Gate (GitHub Actions)

name: SSRF Security Tests

on:
  push:
    branches: [main, "feature/**"]
  pull_request:

jobs:
  ssrf-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install -r requirements.txt pytest pytest-mock requests-mock

      - name: Run SSRF unit tests
        run: pytest tests/test_ssrf_prevention.py -v --tb=short

      - name: OWASP ZAP SSRF scan (baseline)
        uses: zaproxy/action-[email protected]
        with:
          target: ${{ secrets.STAGING_URL }}
          rules_file_name: ".zap/ssrf-rules.tsv"
          fail_action: true

Compliance Mapping

Framework Control Satisfied By
OWASP ASVS 5.2.6 Verify URL is restricted to permitted schemes Step 1: scheme allowlist in URL parser
OWASP ASVS 5.2.7 Verify host resolution is blocked for internal ranges Step 2: pre-flight DNS + IP blocklist
OWASP ASVS 5.2.8 Prevent SSRF via DNS rebinding Step 3: pinned-IP dialer
OWASP Top 10 A10:2021 Server-Side Request Forgery (SSRF) All steps combined
NIST SP 800-53 SI-10 Information Input Validation Steps 1–2: scheme and IP validation
NIST SP 800-53 SC-7 Boundary Protection Steps 4–5: NetworkPolicy, egress proxy
SOC 2 CC6.6 Logical access controls / boundary protection Step 4: egress controls; Step 5: IMDSv2
PCI DSS 6.3.2 Secure coding practices for custom software Steps 1–3: application-layer validation
PCI DSS 6.4.1 Network segmentation between environments Step 4: Kubernetes NetworkPolicy

Audit evidence to collect: structured logs capturing timestamp, source_ip, requested_url, resolved_ip, validation_result, and http_status for every outbound request; SIEM alert rules triggering on requests resolving to RFC 1918 or link-local ranges; WAF/egress proxy rule exports demonstrating blocked SSRF payloads; CI gate results from the test suite above.


Common Pitfalls Checklist


Frequently Asked Questions

Why is URL validation alone insufficient for SSRF prevention?

Attackers routinely bypass string-level validation using DNS rebinding, IPv6 encoding (::ffff:127.0.0.1), or protocol smuggling (gopher://, percent-encoded schemes). The validation and the actual TCP connection are separate events, and anything that changes between them — including DNS TTL expiry — can be weaponised. Defence requires DNS pre-resolution, IP pinning, and a network-layer egress control that is independent of application code.

How do cloud metadata services increase SSRF risk?

Cloud instances expose IAM credentials, SSH keys, and internal routing tables via link-local metadata endpoints reachable at 169.254.169.254 on AWS, GCP, and Azure. A single SSRF request to the right path returns temporary credentials valid for hours, granting the attacker full control over cloud resources without ever accessing a secret store. IMDSv2 enforcement and hop-limit restriction are non-negotiable for any cloud workload that accepts user-supplied URLs.

When should I use an egress proxy instead of in-process IP validation?

Both. Use in-process validation as the first line of defence — it is fast, deterministic, and catches malformed input before it touches the network. Use an egress proxy (Squid, Envoy, or a cloud-native gateway) as the second line of defence in case a bug in the application validator is introduced over time. A service mesh EgressGateway provides a third layer with policy-as-code auditability. Reviewing attack surface mapping techniques helps identify which outbound call paths need all three layers versus which need only two.

How do I handle legitimate use cases that require fetching user-supplied URLs?

Maintain an explicit domain allowlist rather than a blocklist. Accept only URLs whose final resolved hostname matches an entry in the allowlist (e.g., a known set of webhook providers). For open-ended use cases (e.g., social media link previews), run the fetch in a sandboxed process with no access to internal network segments — use a separate Cloud Run service or Lambda with a dedicated VPC that has no route to private subnets. This applies trust boundary isolation at the infrastructure level, not just the code level.