FlawOpen

flawopen.com/ssrf/Rust

● CWE-918 · High
FlawOpen Security Research

Server-Side Request Forgery (SSRF) in Rust (reqwest, Axum)

reqwest::get(&input.url) lets any request make your Rust service call 127.0.0.1, internal APIs or the cloud metadata service at 169.254.169.254 (CWE-918), and a loopback check on the first resolved address misses private and link-local ranges, loses to DNS rebinding and is bypassed by the redirects reqwest follows. How a custom dns_resolver that returns only public addresses, a check for IP-literal hosts, no redirects and no proxy constrain every connection the client makes.

💡 Plain English Explainer (ELI5)

Imagine a parcel depot where a courier can use forwarding slips and a proxy route chosen by the building’s environment. Checking the label at the front desk does not prove where the courier will actually go. In Rust, a reqwest::Client can follow redirects and inherit HTTP_PROXY or HTTPS_PROXY settings. ClientBuilder::dns_resolver installs a controlled Resolve directory that filters the addresses reqwest will use; Host::Ipv4 and Host::Ipv6 literals must be checked separately because they skip name resolution. Policy::none() rejects redirects, no_proxy() removes environment proxies, and spawn_blocking keeps the synchronous system lookup from stalling the async runtime.

Core Concepts & Subsystem Terms

reqwest client defaults
A default reqwest::Client follows up to 10 redirects, uses the HTTP_PROXY/HTTPS_PROXY environment variables and has no overall timeout. A public URL answering Location: http://169.254.169.254/ reaches the metadata service after any check on the original URL.
A loopback check on the first address
to_socket_addrs().next() looks at one address of possibly many, and is_loopback() alone lets 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.169.254, 0.0.0.0 and CGNAT addresses through. The blocking lookup also stalls the async runtime.
Check-then-fetch and DNS rebinding
The check resolves the hostname once and reqwest resolves it again when it connects. A DNS server with a short TTL can return a public address to the check and 127.0.0.1 to the client, so the check proves nothing about the connection.
ClientBuilder::dns_resolver
ClientBuilder::dns_resolver replaces reqwest's name resolution: the client connects only to the socket addresses your Resolve implementation returns, for every request. Hosts written as IP literals are not resolved, so check Url::host() for Host::Ipv4 and Host::Ipv6 before sending, and call no_proxy() so an environment proxy does not become the real destination.
Ipv6Addr::to_ipv4_mapped and address classes
to_ipv4_mapped() turns ::ffff:127.0.0.1 back into 127.0.0.1. Ipv4Addr has stable is_private, is_loopback, is_link_local, is_multicast, is_broadcast and is_documentation; is_global is still unstable, so CGNAT and 0.0.0.0/8 need explicit checks and IPv6 is limited to global unicast 2000::/3.

Framework mappings

Step-by-Step Attack Flow

Step 1

A user supplies a URL

An Axum handler extracts FetchRequest from JSON and passes url to reqwest::get or a default Client.

Step 2

The service connects to an internal address

A DNS precheck does not pin the socket: the HTTP client resolves the host when it connects, so rebinding can change a public answer to a private destination. ClientBuilder::dns_resolver supplies reqwest addresses; IP literals must be checked separately.

Step 3

The internal service answers the trusted caller

The default reqwest::Client follows up to 10 redirects by default. A public preview or webhook URL can therefore send the backend to 169.254.169.254 after the original URL was checked.

Step 4

The attacker reads secrets or pivots

The response path creates the disclosure: an Axum handler that returns the reqwest response body or status. Return only the data the feature needs, and keep internal credentials and response bodies out of caller-controlled fetch results.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// fetch.rs: both handlers let the caller choose the destination
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use std::net::ToSocketAddrs;

#[derive(Deserialize)]
pub struct FetchRequest {
    url: String,
}

// default client: follows redirects, uses env proxies, no timeout
pub async fn preview(Json(input): Json<FetchRequest>) -> Result<String, StatusCode> {
    let resp = reqwest::get(&input.url).await.map_err(|_| StatusCode::BAD_GATEWAY)?;
    let text = resp.text().await.map_err(|_| StatusCode::BAD_GATEWAY)?;
    Ok(text.chars().take(5000).collect())
}

// first address, loopback only, blocking lookup; reqwest resolves again
pub async fn test_webhook(Json(input): Json<FetchRequest>) -> Result<Json<serde_json::Value>, StatusCode> {
    let url = reqwest::Url::parse(&input.url).map_err(|_| StatusCode::BAD_REQUEST)?;
    let host = url.host_str().unwrap_or_default();
    let first = (host, 80).to_socket_addrs().ok().and_then(|mut addrs| addrs.next());
    if matches!(first, Some(addr) if addr.ip().is_loopback()) {
        return Err(StatusCode::BAD_REQUEST);
    }
    let resp = reqwest::Client::new()
        .post(url)
        .json(&serde_json::json!({ "ping": true }))
        .send()
        .await
        .map_err(|_| StatusCode::BAD_GATEWAY)?;
    Ok(Json(serde_json::json!({ "status": resp.status().as_u16() })))
}
HARDENED SECURE PATCH
// fetch.rs: reqwest may only connect to public addresses
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use reqwest::redirect::Policy;
use reqwest::{Client, Url};
use serde::Deserialize;
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::sync::Arc;
use std::time::Duration;
use url::Host;

#[derive(Deserialize)]
pub struct FetchRequest {
    url: String,
}

// reject private, loopback, link-local, CGNAT and non-global IPv6
fn is_public(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => {
            let o = v4.octets();
            !(o[0] == 0 || v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_multicast()
                || v4.is_broadcast() || v4.is_documentation() || (o[0] == 100 && (o[1] & 0xc0) == 64))
        }
        IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
            Some(v4) => is_public(IpAddr::V4(v4)),
            None => (v6.segments()[0] & 0xe000) == 0x2000,
        },
    }
}

// reqwest connects only to the addresses returned here
struct PublicResolver;

impl Resolve for PublicResolver {
    fn resolve(&self, name: Name) -> Resolving {
        let host = name.as_str().to_owned();
        Box::pin(async move {
            let addrs: Vec<SocketAddr> =
                tokio::task::spawn_blocking(move || (host.as_str(), 0).to_socket_addrs().map(Iterator::collect))
                    .await??;
            if addrs.is_empty() || !addrs.iter().all(|a| is_public(a.ip())) {
                return Err("blocked destination".into());
            }
            let addrs: Addrs = Box::new(addrs.into_iter());
            Ok(addrs)
        })
    }
}

// no redirects, no proxy, timeouts; IP literals are checked in checked_url
pub fn guarded_client() -> reqwest::Result<Client> {
    Client::builder()
        .dns_resolver(Arc::new(PublicResolver))
        .redirect(Policy::none())
        .no_proxy()
        .connect_timeout(Duration::from_secs(3))
        .timeout(Duration::from_secs(10))
        .build()
}

fn checked_url(raw: &str) -> Option<Url> {
    let url = Url::parse(raw).ok()?;
    if !matches!(url.scheme(), "http" | "https") {
        return None;
    }
    match url.host()? {
        Host::Ipv4(ip) if !is_public(IpAddr::V4(ip)) => None,
        Host::Ipv6(ip) if !is_public(IpAddr::V6(ip)) => None,
        _ => Some(url),
    }
}

async fn first_bytes(mut resp: reqwest::Response, limit: usize) -> reqwest::Result<Vec<u8>> {
    let mut out = Vec::new();
    while let Some(chunk) = resp.chunk().await? {
        out.extend_from_slice(&chunk);
        if out.len() >= limit {
            out.truncate(limit);
            break;
        }
    }
    Ok(out)
}

// at most 5000 bytes are read from any response
pub async fn preview(State(http): State<Client>, Json(input): Json<FetchRequest>) -> Result<String, StatusCode> {
    let url = checked_url(&input.url).ok_or(StatusCode::BAD_REQUEST)?;
    let resp = http.get(url).send().await.map_err(|_| StatusCode::BAD_GATEWAY)?;
    let body = first_bytes(resp, 5000).await.map_err(|_| StatusCode::BAD_GATEWAY)?;
    Ok(String::from_utf8_lossy(&body).into_owned())
}

pub async fn test_webhook(
    State(http): State<Client>,
    Json(input): Json<FetchRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let url = checked_url(&input.url).ok_or(StatusCode::BAD_REQUEST)?;
    let resp = http
        .post(url)
        .json(&serde_json::json!({ "ping": true }))
        .send()
        .await
        .map_err(|_| StatusCode::BAD_GATEWAY)?;
    Ok(Json(serde_json::json!({ "status": resp.status().as_u16() })))
}

Engineering & System Hardening Checklist

Sources