Server-Side Request Forgery is one of those vulnerability classes that I’ve grown to respect more and more the deeper I dig into it. The idea is simple, you trick a server into making HTTP requests to destinations you choose, turning it into your personal proxy. It can reach internal services, cloud metadata endpoints, and private networks that you’d never touch directly from the outside. OWASP gave SSRF its own category (A10) in 2021, and reading through the rationale, it was overdue. The case studies are striking, a single SSRF against http://169.254.169.254/ on AWS can leak IAM credentials and compromise an entire account. In this post, I’ll walk through Python, Java, Go, and JavaScript examples, from the textbook URL-in-a-parameter to the subtle redirect-chain and DNS rebinding variants that make SSRF so hard to defend against.

Why SSRF Is Devastating in Cloud Environments

Traditional SSRF let attackers scan internal networks, which was bad enough. But cloud SSRF? That’s a whole different level of impact. Cloud providers expose instance metadata services on link-local addresses, and the research on exploiting these is well-documented:

  • AWS: http://169.254.169.254/latest/meta-data/iam/security-credentials/
  • GCP: http://metadata.google.internal/computeMetadata/v1/
  • Azure: http://169.254.169.254/metadata/instance

These endpoints hand back temporary credentials, service account tokens, and configuration data. An SSRF that reaches them gives the attacker the same permissions as the server’s IAM role, and in many environments, that’s often enough to read S3 buckets, invoke Lambda functions, or access databases. What surprised me researching this was how quickly teams go from “it’s just an SSRF” to full-blown incident response, the escalation path is remarkably short.

Beyond metadata, SSRF can:

  • Access internal microservices that have no authentication (they trust the network boundary, a pattern that shows up constantly in microservice architectures)
  • Read files via file:// protocol handlers
  • Interact with internal Redis, Elasticsearch, or Memcached instances
  • Bypass firewalls and VPNs by using the server as a pivot point

The Easy-to-Spot Version

Python: Direct URL Fetch from User Input

from flask import Flask, request, jsonify
import requests

app = Flask(__name__)

@app.route("/api/preview", methods=["POST"])
def preview_url():
    data = request.get_json()
    url = data.get("url", "")
    try:
        resp = requests.get(url, timeout=5)
        return jsonify({
            "status": resp.status_code,
            "content_type": resp.headers.get("Content-Type", ""),
            "body": resp.text[:1000],
        })
    except requests.RequestException as e:
        return jsonify({"error": str(e)}), 400

The user supplies a URL, and the server fetches it with zero validation. Sending {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"} would return IAM credentials right in the response body. Every SAST tool flags requests.get() with user-controlled input, and yet this pattern keeps showing up in production, I’ve run into it myself during code reviews, and public bug bounty reports are full of examples.

JavaScript: Node.js Fetch Proxy

const express = require("express");
const axios = require("axios");
const app = express();
app.use(express.json());

app.post("/api/fetch", async (req, res) => {
    const { url } = req.body;
    try {
        const response = await axios.get(url, { timeout: 5000 });
        res.json({
            status: response.status,
            headers: response.headers,
            data: response.data,
        });
    } catch (err) {
        res.status(400).json({ error: err.message });
    }
});

app.listen(3000);

Same pattern, different language. The axios.get(url) call with user-supplied url is a direct SSRF. What makes this one even worse is that the response returns the full headers and data, maximising information leakage. These “URL preview” features get built without a second thought about what the server can reach internally, it’s one of those cases where the feature request itself is the vulnerability.

The Hard-to-Spot Version

Python: Allowlist Bypass via DNS Rebinding

import ipaddress
import socket
from urllib.parse import urlparse
from flask import Flask, request, jsonify
import requests

app = Flask(__name__)

ALLOWED_SCHEMES = {"http", "https"}

def is_safe_url(url):
    parsed = urlparse(url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        return False
    try:
        hostname = parsed.hostname
        ip = socket.getaddrinfo(hostname, None)[0][4][0]
        addr = ipaddress.ip_address(ip)
        if addr.is_private or addr.is_loopback or addr.is_link_local:
            return False
    except (socket.gaierror, ValueError):
        return False
    return True

@app.route("/api/preview", methods=["POST"])
def preview_url():
    data = request.get_json()
    url = data.get("url", "")
    if not is_safe_url(url):
        return jsonify({"error": "URL not allowed"}), 403
    resp = requests.get(url, timeout=5)
    return jsonify({"status": resp.status_code, "body": resp.text[:1000]})

Here’s the thing, this looks secure. It resolves the hostname, checks if the IP is private/loopback/link-local, and blocks disallowed schemes. I’d forgive a reviewer for signing off on this. But there’s a Time-of-Check-to-Time-of-Use (TOCTOU) gap that makes it exploitable: is_safe_url() resolves the DNS name and checks the IP, then requests.get() resolves the DNS name again. With DNS rebinding, an attacker sets up a domain that alternates between a public IP (passes the check) and 169.254.169.254 (used by the actual request). The first resolution returns the safe IP; the second returns the metadata endpoint. What I found fascinating researching this technique is how reliable it can be, the attack is well-documented and there are open-source tools that automate the DNS rebinding setup.

Java: SSRF via URL Redirect Following

import org.springframework.web.bind.annotation.*;
import java.net.*;
import java.io.*;

@RestController
public class WebhookController {

    private static final Set<String> BLOCKED_HOSTS = Set.of(
        "169.254.169.254", "metadata.google.internal", "localhost", "127.0.0.1"
    );

    @PostMapping("/api/webhooks/test")
    public ResponseEntity<?> testWebhook(@RequestBody Map<String, String> body) {
        String targetUrl = body.getOrDefault("url", "");
        try {
            URL url = new URL(targetUrl);
            if (BLOCKED_HOSTS.contains(url.getHost().toLowerCase())) {
                return ResponseEntity.status(403).body(Map.of("error", "Blocked host"));
            }

            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setConnectTimeout(5000);
            conn.setReadTimeout(5000);
            // Java follows redirects by default
            int status = conn.getResponseCode();
            String responseBody = new String(conn.getInputStream().readAllBytes());
            return ResponseEntity.ok(Map.of("status", status, "body", responseBody));
        } catch (Exception e) {
            return ResponseEntity.status(400).body(Map.of("error", e.getMessage()));
        }
    }
}

What’s interesting about this one is that the developer clearly thought about SSRF, they built a blocklist. But they missed the fact that HttpURLConnection follows HTTP redirects by default (up to 20 hops). An attacker hosts a page at http://attacker.com/redirect that returns a 302 redirect to http://169.254.169.254/latest/meta-data/. The initial host check passes (attacker.com is not in the blocklist), and the redirect silently fetches the metadata endpoint. The blocklist is never consulted for redirect targets. This redirect bypass is one of the most common SSRF evasion techniques in the literature, and it’s easy to see why, the defence looks solid until you consider the redirect behaviour.

Go: SSRF Through Partial URL Validation

package main

import (
    "io"
    "net"
    "net/http"
    "net/url"
    "strings"
)

var blockedCIDRs = []string{"169.254.0.0/16", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8"}

func isBlockedIP(host string) bool {
    ips, err := net.LookupIP(host)
    if err != nil {
        return true
    }
    for _, ip := range ips {
        for _, cidr := range blockedCIDRs {
            _, network, _ := net.ParseCIDR(cidr)
            if network.Contains(ip) {
                return true
            }
        }
    }
    return false
}

func fetchHandler(w http.ResponseWriter, r *http.Request) {
    targetURL := r.URL.Query().Get("url")
    parsed, err := url.Parse(targetURL)
    if err != nil || parsed.Scheme == "" {
        http.Error(w, "invalid url", http.StatusBadRequest)
        return
    }

    host := parsed.Hostname()
    if isBlockedIP(host) {
        http.Error(w, "blocked", http.StatusForbidden)
        return
    }

    resp, err := http.Get(targetURL)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()
    io.Copy(w, resp.Body)
}

This one validates the hostname against private CIDR ranges, a solid approach, and the developer deserves credit for thinking about it. But http.Get() uses the default http.Client, which follows redirects. The CIDR check applies only to the initial hostname. A redirect to an internal IP bypasses the check entirely. What I found particularly interesting digging into this is the IPv6 angle: http://[::ffff:169.254.169.254]/ (IPv4-mapped IPv6) may bypass the IPv4 CIDR checks depending on how net.LookupIP resolves the address. It’s worth testing for this in any SSRF review.

JavaScript: SSRF via URL Parser Inconsistency

const express = require("express");
const http = require("http");
const { URL } = require("url");
const app = express();
app.use(express.json());

const BLOCKED = ["169.254.169.254", "127.0.0.1", "localhost", "0.0.0.0"];

app.post("/api/check-url", (req, res) => {
    const { url: targetUrl } = req.body;
    try {
        const parsed = new URL(targetUrl);
        if (BLOCKED.includes(parsed.hostname)) {
            return res.status(403).json({ error: "blocked" });
        }

        http.get(targetUrl, (response) => {
            let data = "";
            response.on("data", (chunk) => { data += chunk; });
            response.on("end", () => {
                res.json({ status: response.statusCode, body: data.slice(0, 1000) });
            });
        }).on("error", (err) => {
            res.status(400).json({ error: err.message });
        });
    } catch (err) {
        res.status(400).json({ error: "Invalid URL" });
    }
});

The blocklist checks parsed.hostname from the URL constructor. But here’s what the research on URL parser inconsistencies shows, URL parsers can be tricked with unusual formats: http://169.254.169.254.xip.io/ resolves to the metadata IP but has a different hostname. Decimal IP encoding (http://2852039166/ = 169.254.169.254), octal encoding (http://0251.0376.0251.0376/), and URL-encoded characters can all bypass string-based hostname blocklists. The number of encoding tricks available is honestly surprising, there are entire cheat sheets dedicated to SSRF bypass techniques, and they keep growing.

Detection Strategies

Static Analysis

Tool Language What It Catches Limitations
Bandit Python requests.get() with non-constant URL (B310) Cannot evaluate URL validation logic
Semgrep All User input flowing to HTTP client calls Custom rules needed for framework-specific patterns
SpotBugs Java URL.openConnection() with tainted input Does not detect redirect-based bypasses
gosec Go http.Get() with variable URL Cannot evaluate CIDR validation logic
NodeJsScan JavaScript http.get() / axios.get() with user input Limited detection of URL parser inconsistencies

Manual Review Indicators

When reviewing code for SSRF, these are the patterns worth focusing on:

  1. Any HTTP client call where the URL originates from user input, even partially (e.g., user controls the path but not the host). SSRF through path-only control is documented in several public disclosures.
  2. Allowlist/blocklist checks that only validate the initial URL, redirect following without re-validation is the most common SSRF bypass technique in the literature.
  3. DNS resolution followed by a separate HTTP request, TOCTOU gap enables DNS rebinding. The mechanics are well-documented and the attack is reproducible.
  4. String-based hostname comparison instead of IP-based comparison after resolution.
  5. Missing scheme validation, file://, gopher://, dict:// protocols can be exploited. The gopher:// protocol in particular is interesting because it allows sending raw bytes to internal services like Redis.
  6. URL construction from user-supplied components, even if the base URL is hardcoded, user-controlled path segments can include @ to change the effective host. This one trips up a lot of developers when they first encounter it.

Remediation

Python: Proper SSRF Prevention

import ipaddress
import socket
from urllib.parse import urlparse
from flask import Flask, request, jsonify
import requests
from requests.adapters import HTTPAdapter

app = Flask(__name__)

ALLOWED_SCHEMES = {"http", "https"}

class SSRFSafeAdapter(HTTPAdapter):
    """Custom adapter that validates resolved IPs before connecting."""

    def send(self, request, **kwargs):
        parsed = urlparse(request.url)
        hostname = parsed.hostname
        try:
            for info in socket.getaddrinfo(hostname, parsed.port or 80):
                ip = ipaddress.ip_address(info[4][0])
                if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
                    raise ValueError(f"Blocked IP: {ip}")
        except socket.gaierror:
            raise ValueError(f"Cannot resolve: {hostname}")
        return super().send(request, **kwargs)

def create_safe_session():
    session = requests.Session()
    session.max_redirects = 3
    adapter = SSRFSafeAdapter()
    session.mount("http://", adapter)
    session.mount("https://", adapter)
    return session

@app.route("/api/preview", methods=["POST"])
def preview_url():
    data = request.get_json()
    url = data.get("url", "")
    parsed = urlparse(url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        return jsonify({"error": "Scheme not allowed"}), 403

    session = create_safe_session()
    try:
        resp = session.get(url, timeout=5, allow_redirects=True)
        return jsonify({"status": resp.status_code, "body": resp.text[:1000]})
    except (requests.RequestException, ValueError) as e:
        return jsonify({"error": "Request blocked or failed"}), 403

This approach is what the security community generally recommends. The custom HTTPAdapter validates the resolved IP at connection time, after DNS resolution and before the TCP connection. This eliminates the TOCTOU gap. The validation runs on every request, including redirects. The session limits redirects to 3 to prevent redirect loops. It’s a solid pattern that holds up well in practice.

Java: Disable Redirects and Validate at Connection Time

import org.springframework.web.bind.annotation.*;
import java.net.*;
import java.io.*;

@RestController
public class WebhookController {

    @PostMapping("/api/webhooks/test")
    public ResponseEntity<?> testWebhook(@RequestBody Map<String, String> body) {
        String targetUrl = body.getOrDefault("url", "");
        try {
            URL url = new URL(targetUrl);
            if (!isAllowedScheme(url.getProtocol())) {
                return ResponseEntity.status(403).body(Map.of("error", "Scheme not allowed"));
            }
            if (isBlockedAddress(url.getHost())) {
                return ResponseEntity.status(403).body(Map.of("error", "Blocked host"));
            }

            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setInstanceFollowRedirects(false);  // Disable automatic redirects
            conn.setRequestMethod("GET");
            conn.setConnectTimeout(5000);
            conn.setReadTimeout(5000);

            int status = conn.getResponseCode();
            if (status >= 300 && status < 400) {
                return ResponseEntity.ok(Map.of("status", status, "note", "Redirect not followed"));
            }
            String responseBody = new String(conn.getInputStream().readAllBytes());
            return ResponseEntity.ok(Map.of("status", status, "body", responseBody));
        } catch (Exception e) {
            return ResponseEntity.status(400).body(Map.of("error", "Request failed"));
        }
    }

    private boolean isBlockedAddress(String host) {
        try {
            InetAddress[] addresses = InetAddress.getAllByName(host);
            for (InetAddress addr : addresses) {
                if (addr.isLoopbackAddress() || addr.isLinkLocalAddress()
                    || addr.isSiteLocalAddress() || addr.isAnyLocalAddress()) {
                    return true;
                }
            }
        } catch (UnknownHostException e) {
            return true;
        }
        return false;
    }

    private boolean isAllowedScheme(String scheme) {
        return "http".equals(scheme) || "https".equals(scheme);
    }
}

Redirects are disabled with setInstanceFollowRedirects(false). The IP validation uses InetAddress methods (isLoopbackAddress, isLinkLocalAddress, isSiteLocalAddress) which handle both IPv4 and IPv6 correctly. Unknown hosts are blocked by default. What I like about this approach is its straightforwardness, it doesn’t leave room for redirect shenanigans.

Go: Custom Transport with IP Validation

package main

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

func newSSRFSafeClient() *http.Client {
    dialer := &net.Dialer{Timeout: 5 * time.Second}

    transport := &http.Transport{
        DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
            host, port, err := net.SplitHostPort(addr)
            if err != nil {
                return nil, err
            }
            ips, err := net.LookupIP(host)
            if err != nil {
                return nil, err
            }
            for _, ip := range ips {
                if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsPrivate() {
                    return nil, fmt.Errorf("blocked IP: %s", ip)
                }
            }
            return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port))
        },
    }

    return &http.Client{
        Transport: transport,
        Timeout:   10 * time.Second,
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            if len(via) >= 3 {
                return fmt.Errorf("too many redirects")
            }
            return nil
        },
    }
}

func safeFetchHandler(w http.ResponseWriter, r *http.Request) {
    targetURL := r.URL.Query().Get("url")
    parsed, err := url.Parse(targetURL)
    if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
        http.Error(w, "invalid url", http.StatusBadRequest)
        return
    }

    client := newSSRFSafeClient()
    resp, err := client.Get(targetURL)
    if err != nil {
        http.Error(w, "request blocked or failed", http.StatusForbidden)
        return
    }
    defer resp.Body.Close()

    limited := io.LimitReader(resp.Body, 10240)
    io.Copy(w, limited)
}

The custom DialContext function intercepts every TCP connection, including those from redirects, and validates the resolved IP before connecting. This is the most robust approach because the validation happens at the network layer, not the application layer. The CheckRedirect function limits redirect depth. For any Go service that needs to fetch user-supplied URLs, this is the pattern worth adopting.

SSRF Prevention Checklist

Here’s a checklist distilled from the research and documented bypass techniques:

  1. Validate at connection time, not request time. DNS rebinding defeats pre-request validation. This is well-demonstrated in the security research.
  2. Disable or limit automatic redirect following. Re-validate every redirect target. Redirect-based bypass is the most commonly documented SSRF evasion technique.
  3. Use IP-based validation, not hostname-based. Resolve the hostname and check the IP against private/reserved ranges.
  4. Block all non-HTTP(S) schemes. file://, gopher://, dict://, ftp:// are all exploitable. The gopher:// protocol is particularly interesting for sending raw commands to internal services.
  5. Handle IPv6 correctly. IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) can bypass IPv4-only checks. This is worth testing explicitly.
  6. Use network-level controls as defence in depth. IMDSv2 on AWS requires a PUT request with a hop limit, making SSRF exploitation harder. Firewall rules blocking metadata IPs from application subnets add another layer.
  7. Limit response size. Prevent the server from being used to exfiltrate large internal resources.