What SSRF looks like in real applications

SSRF often appears in features that “fetch by URL”:

  • image or document importers
  • webhook testers
  • link preview generators
  • PDF or HTML renderers
  • integrations that call third-party APIs on behalf of users

A typical vulnerable pattern is simple:

let response = reqwest::get(user_supplied_url).await?;
let body = response.text().await?;

If user_supplied_url points to http://169.254.169.254/latest/meta-data/, the application may leak cloud credentials. If it points to http://localhost:8080/admin, it may expose internal admin endpoints. If it points to a private IP range, the attacker may scan internal services through your server.

The key lesson: treat URLs as untrusted input, not just strings.


SSRF defense strategy

A robust defense has multiple layers:

  1. Parse and validate the URL before any request
  2. Restrict schemes to a small allowlist
  3. Resolve the hostname and reject private or special-use IPs
  4. Block redirects to disallowed destinations
  5. Limit request size, timeouts, and methods
  6. Avoid sending sensitive headers to untrusted hosts
  7. Prefer allowlists over blocklists

No single check is enough. Attackers often bypass one layer by using redirects, DNS tricks, alternate IP notations, or unexpected schemes.


Validate the URL structure first

Use a real URL parser, not string matching. The url crate is a good choice because it separates scheme, host, port, path, and query.

A safe baseline is to allow only http and https:

use url::Url;

fn parse_allowed_url(input: &str) -> Result<Url, &'static str> {
    let url = Url::parse(input).map_err(|_| "invalid URL")?;

    match url.scheme() {
        "http" | "https" => {}
        _ => return Err("unsupported scheme"),
    }

    if url.host_str().is_none() {
        return Err("missing host");
    }

    Ok(url)
}

This rejects obvious abuse such as file:///etc/passwd, ftp://..., and malformed URLs. However, it does not yet stop requests to internal IPs.


Reject private and special-use destinations

The most important SSRF control is preventing access to internal network ranges. That includes:

  • loopback: 127.0.0.0/8, ::1
  • private networks: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  • link-local: 169.254.0.0/16, fe80::/10
  • multicast and reserved ranges
  • cloud metadata endpoints such as 169.254.169.254

To do this correctly, resolve the hostname and inspect the resulting IP addresses. The ipnet crate can help with CIDR checks.

use ipnet::{IpNet, Ipv4Net, Ipv6Net};
use std::net::IpAddr;

fn is_disallowed_ip(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => {
            let private = [
                Ipv4Net::new("10.0.0.0".parse().unwrap(), 8).unwrap(),
                Ipv4Net::new("172.16.0.0".parse().unwrap(), 12).unwrap(),
                Ipv4Net::new("192.168.0.0".parse().unwrap(), 16).unwrap(),
                Ipv4Net::new("127.0.0.0".parse().unwrap(), 8).unwrap(),
                Ipv4Net::new("169.254.0.0".parse().unwrap(), 16).unwrap(),
            ];
            private.iter().any(|net| net.contains(&v4))
        }
        IpAddr::V6(v6) => {
            let blocked = [
                Ipv6Net::new("::1".parse().unwrap(), 128).unwrap(),
                Ipv6Net::new("fe80::".parse().unwrap(), 10).unwrap(),
                Ipv6Net::new("fc00::".parse().unwrap(), 7).unwrap(),
            ];
            blocked.iter().any(|net| net.contains(&v6))
        }
    }
}

In practice, you should also block any address that is not publicly routable for your use case. If the feature is meant to fetch only public websites, then private, loopback, and link-local addresses should all be rejected.


Beware of DNS rebinding and alternate IP forms

A common mistake is to validate only the hostname, then let the HTTP client resolve it later. That leaves room for DNS rebinding: the hostname initially resolves to a public IP during validation, then later resolves to a private IP when the request is made.

Another bypass uses alternate IP representations:

  • decimal: http://2130706433/ for 127.0.0.1
  • hexadecimal: http://0x7f000001/
  • octal forms in some parsers
  • IPv6-mapped IPv4 addresses

To reduce risk:

  • parse and resolve the host yourself
  • validate the resolved IPs, not just the hostname string
  • avoid custom DNS behavior that can change between validation and request
  • consider pinning the resolved IP for the actual request when feasible

If your application cannot safely control resolution, it is often better to proxy outbound requests through a network layer that enforces egress policy.


Control redirects carefully

Redirects are a frequent SSRF bypass. A URL that looks safe may redirect to an internal address after the first response.

For example:

  1. user submits https://example.com/image
  2. server returns 302 Location: http://169.254.169.254/latest/meta-data/
  3. your client follows the redirect automatically

With reqwest, you can limit or disable redirect following and inspect each hop.

use reqwest::{Client, redirect::Policy};
use std::time::Duration;

fn build_client() -> Client {
    Client::builder()
        .timeout(Duration::from_secs(5))
        .redirect(Policy::none())
        .build()
        .unwrap()
}

If your feature must support redirects, validate every Location target before following it. Do not assume the original URL remains safe.


Build a safer fetch pipeline

A practical SSRF-resistant flow looks like this:

  1. Parse the input URL
  2. Check the scheme
  3. Resolve the hostname
  4. Reject disallowed IPs
  5. Send the request with strict client settings
  6. Re-check redirects if enabled
  7. Limit response size and timeout

Here is a compact example:

use reqwest::{Client, Url};
use std::net::{IpAddr, ToSocketAddrs};
use std::time::Duration;

fn is_public_ip(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => {
            !(v4.is_private()
                || v4.is_loopback()
                || v4.is_link_local()
                || v4.is_multicast()
                || v4.octets()[0] == 0)
        }
        IpAddr::V6(v6) => {
            !(v6.is_loopback()
                || v6.is_unspecified()
                || v6.is_unique_local()
                || v6.is_unicast_link_local()
                || v6.is_multicast())
        }
    }
}

fn validate_url(url: &Url) -> Result<(), &'static str> {
    match url.scheme() {
        "http" | "https" => {}
        _ => return Err("unsupported scheme"),
    }

    let host = url.host_str().ok_or("missing host")?;
    let port = url.port_or_known_default().ok_or("missing port")?;

    let addrs = (host, port).to_socket_addrs().map_err(|_| "DNS resolution failed")?;
    let mut saw_public = false;

    for addr in addrs {
        if is_public_ip(addr.ip()) {
            saw_public = true;
        } else {
            return Err("disallowed destination");
        }
    }

    if !saw_public {
        return Err("no public address found");
    }

    Ok(())
}

async fn fetch_remote(input: &str) -> Result<String, Box<dyn std::error::Error>> {
    let url = Url::parse(input)?;
    validate_url(&url)?;

    let client = Client::builder()
        .timeout(Duration::from_secs(5))
        .redirect(reqwest::redirect::Policy::none())
        .build()?;

    let response = client.get(url).send().await?;
    let body = response.text().await?;
    Ok(body)
}

This example is intentionally conservative. It rejects non-public destinations and disables redirects. For many applications, that is the right default.


Common SSRF controls and their tradeoffs

ControlWhat it blocksTradeoff
Scheme allowlistfile://, ftp://, custom handlersMay reject legitimate integrations
IP range filteringLoopback, private, link-local targetsRequires careful IPv4/IPv6 handling
Redirect disablingRedirect-based bypassesSome sites rely on redirects
DNS resolution before requestDNS rebinding and hidden private IPsMore implementation complexity
Request timeoutSlow-loris style resource exhaustionMay cut off slow but valid servers
Response size limitLarge downloads and memory pressureMust decide a maximum size

The safest design is usually the simplest one that meets the product requirement. If users only need to fetch public URLs, do not support internal destinations “just in case.”


Limit request scope and sensitive data exposure

Even when the target is public, outbound requests can leak information if you are not careful.

Best practices:

  • do not forward internal cookies or bearer tokens to user-supplied URLs
  • strip Authorization, Cookie, and custom secret headers
  • use a dedicated outbound client for untrusted destinations
  • set a small timeout
  • cap the response body size
  • avoid automatic decompression if it can inflate huge payloads unexpectedly

A common pattern is to create a separate client with no default auth and a narrow purpose. Never reuse a client configured for internal APIs when fetching untrusted URLs.


When allowlists are better than generic validation

If your application only needs to contact a known set of hosts, use an allowlist. This is much safer than trying to define “safe enough” public internet access.

Examples:

  • webhook delivery only to approved partner domains
  • image import only from your own CDN
  • health checks only to known service endpoints

In those cases, compare the parsed hostname against an explicit list, and ideally pin the expected scheme and port too.

fn host_allowed(host: &str) -> bool {
    matches!(host, "api.example.com" | "cdn.example.com")
}

Allowlisting is especially effective because it removes ambiguity around DNS, redirects, and special IP forms.


Testing your SSRF defenses

Security controls should be tested like any other behavior. Add tests for:

  • http://127.0.0.1
  • http://localhost
  • http://169.254.169.254
  • http://[::1]
  • file:///etc/passwd
  • redirect chains to internal IPs
  • malformed or encoded hostnames
  • decimal or hexadecimal IP representations

Also test the negative case: a normal public URL should still work. SSRF defenses that block too much often get removed later, so make the expected behavior explicit in tests.


Practical checklist

Before shipping a URL-fetching feature, verify that you:

  • parse URLs with a real parser
  • allow only required schemes
  • resolve hostnames before sending requests
  • reject private, loopback, link-local, and metadata IPs
  • disable or validate redirects
  • use short timeouts
  • cap response size
  • avoid forwarding secrets
  • prefer allowlists when possible

If you can answer “yes” to each item, your SSRF exposure is much lower.

Learn more with useful resources