Back

C10: Stop Server-Side Request Forgery - Code Examples

Each pair below shows a vulnerable outbound fetch and the secure version in the same framework. The vulnerable code takes a user-supplied URL and requests it verbatim; the secure code parses the URL, checks the scheme, resolves DNS, validates and pins the resolved IP against reserved ranges, refuses redirects, and bounds the response.

These snippets illustrate the control. In production, prefer a single, centralized, vetted SSRF-safe HTTP client and back it with network egress filtering—never rely on application code alone.

Flask (Python)

Vulnerable

import requests
from flask import Flask, request, Response

app = Flask(__name__)

@app.route('/fetch')
def fetch():
    url = request.args.get('url')          # attacker-controlled
    r = requests.get(url)                  # fetches ANYTHING: file://, 169.254.169.254, 10.0.0.5
    return Response(r.content, r.status_code)   # raw internal body reflected back

Problems: no scheme check (file:// allowed), no IP validation (loopback/metadata/private reachable), redirects auto-followed, DNS re-resolved by the client (rebinding), and the raw response is reflected to the caller.

Secure

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

app = Flask(__name__)

ALLOWED_SCHEMES = {'https'}
MAX_BYTES = 1_000_000
TIMEOUT = 5

def is_blocked_ip(ip_str):
    ip = ipaddress.ip_address(ip_str)
    return (ip.is_private or ip.is_loopback or ip.is_link_local
            or ip.is_reserved or ip.is_multicast or ip.is_unspecified)
    # is_link_local covers 169.254.169.254; is_private covers RFC1918; IPv6 handled too

def resolve_safe_ip(host):
    infos = socket.getaddrinfo(host, None)
    ips = {info[4][0] for info in infos}
    if not ips:
        raise ValueError('no address')
    for ip in ips:                         # every answer must be safe
        if is_blocked_ip(ip):
            raise ValueError(f'blocked address: {ip}')
    return next(iter(ips))                 # pin this exact IP for the connection

@app.route('/fetch')
def fetch():
    url = request.args.get('url', '')
    parts = urlparse(url)
    if parts.scheme.lower() not in ALLOWED_SCHEMES:     # no file://, gopher://, ...
        return jsonify(error='scheme not allowed'), 400
    if not parts.hostname:
        return jsonify(error='invalid url'), 400
    try:
        ip = resolve_safe_ip(parts.hostname)            # validate resolved IP
    except ValueError:
        return jsonify(error='destination not allowed'), 400

    # Connect to the validated IP, preserve Host + SNI, refuse redirects, bound size
    port = parts.port or 443
    pinned = f'https://{ip}:{port}{parts.path or "/"}'
    headers = {'Host': parts.hostname}
    r = requests.get(pinned, headers=headers, timeout=TIMEOUT,
                     allow_redirects=False, stream=True,
                     verify=True, server_hostname=parts.hostname)  # SNI kept via adapter
    if r.is_redirect:
        return jsonify(error='redirects not permitted'), 400

    body = b''
    for chunk in r.iter_content(8192):
        body += chunk
        if len(body) > MAX_BYTES:
            return jsonify(error='response too large'), 502
    return jsonify(status=r.status_code, length=len(body))  # controlled result, not raw body

Note: pinning to the IP while preserving the original Host and TLS SNI usually needs a small custom adapter or a purpose-built SSRF-safe client; the intent shown is "validate and connect to the same address, and never re-resolve the hostname."

Express (Node.js)

Vulnerable

const express = require('express');
const app = express();

app.get('/fetch', async (req, res) => {
    const url = req.query.url;              // attacker-controlled
    const upstream = await fetch(url);      // follows redirects, any scheme/IP
    const body = await upstream.text();
    res.send(body);                         // raw internal response reflected back
});

app.listen(3000);

Secure

const express = require('express');
const dns = require('dns').promises;
const net = require('net');
const app = express();

const ALLOWED_SCHEMES = new Set(['https:']);
const MAX_BYTES = 1_000_000;

function isBlockedIp(ip) {
    // Normalize IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) to its IPv4 form
    if (ip.startsWith('::ffff:') && net.isIPv4(ip.slice(7))) ip = ip.slice(7);
    if (net.isIPv4(ip)) {
        const [a, b] = ip.split('.').map(Number);
        return (a === 127 || a === 10 || a === 0 ||
                (a === 169 && b === 254) ||           // link-local incl. 169.254.169.254
                (a === 172 && b >= 16 && b <= 31) ||
                (a === 192 && b === 168));
    }
    // IPv6: block loopback, unique-local (fc00::/7), link-local (fe80::/10)
    const lo = ip.toLowerCase();
    return lo === '::1' || lo.startsWith('fc') || lo.startsWith('fd') || lo.startsWith('fe80');
}

app.get('/fetch', async (req, res) => {
    let parsed;
    try { parsed = new URL(req.query.url); } catch { return res.status(400).json({ error: 'invalid url' }); }
    if (!ALLOWED_SCHEMES.has(parsed.protocol)) {         // no file:, gopher:, ...
        return res.status(400).json({ error: 'scheme not allowed' });
    }

    let addrs;
    try { addrs = await dns.lookup(parsed.hostname, { all: true }); }
    catch { return res.status(400).json({ error: 'resolve failed' }); }
    if (addrs.some(a => isBlockedIp(a.address))) {        // every answer must be safe
        return res.status(400).json({ error: 'destination not allowed' });
    }
    const pinnedIp = addrs[0].address;                   // pin the validated IP

    // Connect to the pinned IP, keep the Host header, refuse redirects, bound size
    const upstream = await fetch(`https://${pinnedIp}${parsed.pathname}`, {
        headers: { Host: parsed.hostname },
        redirect: 'manual',                              // do NOT follow redirects
        signal: AbortSignal.timeout(5000),
    });
    if ([301, 302, 303, 307, 308].includes(upstream.status)) {
        return res.status(400).json({ error: 'redirects not permitted' });
    }

    const buf = Buffer.from(await upstream.arrayBuffer());
    if (buf.length > MAX_BYTES) return res.status(502).json({ error: 'response too large' });
    res.json({ status: upstream.status, length: buf.length });  // controlled result
});

app.listen(3000);

Java (Spring)

Vulnerable

@RestController
class FetchController {

    @GetMapping("/fetch")
    public ResponseEntity<String> fetch(@RequestParam String url) throws Exception {
        // URL taken verbatim: any scheme, any IP, redirects followed by default
        String body = new RestTemplate().getForObject(url, String.class);
        return ResponseEntity.ok(body);          // raw internal body reflected back
    }
}

Secure

@RestController
class FetchController {

    private static final Set<String> ALLOWED_SCHEMES = Set.of("https");
    private static final int MAX_BYTES = 1_000_000;

    private boolean isBlocked(InetAddress addr) {
        return addr.isLoopbackAddress()          // 127.0.0.0/8, ::1
            || addr.isLinkLocalAddress()         // 169.254.0.0/16 incl. 169.254.169.254, fe80::/10
            || addr.isSiteLocalAddress()         // 10/8, 172.16/12, 192.168/16
            || addr.isAnyLocalAddress()          // 0.0.0.0, ::
            || addr.isMulticastAddress();
    }

    @GetMapping("/fetch")
    public ResponseEntity<?> fetch(@RequestParam String url) throws Exception {
        URI uri = URI.create(url);
        if (uri.getScheme() == null || !ALLOWED_SCHEMES.contains(uri.getScheme().toLowerCase()))
            return ResponseEntity.badRequest().body("scheme not allowed");   // no file:, gopher:
        String host = uri.getHost();
        if (host == null) return ResponseEntity.badRequest().body("invalid url");

        // Resolve and validate EVERY address; pick a validated one to pin
        InetAddress[] addrs = InetAddress.getAllByName(host);
        for (InetAddress a : addrs)
            if (isBlocked(a)) return ResponseEntity.badRequest().body("destination not allowed");
        InetAddress pinned = addrs[0];

        // Client that does NOT follow redirects, with timeouts; connect to the pinned IP
        HttpClient client = HttpClient.newBuilder()
            .followRedirects(HttpClient.Redirect.NEVER)      // redirect-to-internal blocked
            .connectTimeout(Duration.ofSeconds(5))
            .build();
        URI pinnedUri = new URI("https", null, pinned.getHostAddress(),
                                uri.getPort(), uri.getPath(), uri.getQuery(), null);
        HttpRequest req = HttpRequest.newBuilder(pinnedUri)
            .header("Host", host)                            // preserve original Host
            .timeout(Duration.ofSeconds(5))
            .GET().build();

        HttpResponse<byte[]> resp = client.send(req, HttpResponse.BodyHandlers.ofByteArray());
        if (resp.body().length > MAX_BYTES)
            return ResponseEntity.status(502).body("response too large");
        return ResponseEntity.ok(Map.of("status", resp.statusCode(),
                                        "length", resp.body().length));  // controlled result
    }
}

What Changed, and Why

ConcernVulnerableSecure
SchemeAny (file://, gopher:// allowed)Allow-list: https only
DestinationHostname used verbatim, no IP checkResolve, reject private/loopback/link-local/reserved
DNS rebindingClient re-resolves independentlyValidated IP is pinned for the connection
RedirectsFollowed automaticallyRefused (or would be re-validated per hop)
ResponseRaw upstream body reflectedSize-limited, controlled result only
TimeoutsNoneConnect + read timeouts on every request

Next Steps