flawopen.com/ssrf/Rust
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.
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::Clientfollows up to 10 redirects, uses theHTTP_PROXY/HTTPS_PROXYenvironment variables and has no overall timeout. A public URL answeringLocation: http://169.254.169.254/reaches the metadata service after any check on the original URL. A loopback check on the first addressto_socket_addrs().next()looks at one address of possibly many, andis_loopback()alone lets10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.169.254,0.0.0.0and 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.1to the client, so the check proves nothing about the connection. ClientBuilder::dns_resolverClientBuilder::dns_resolverreplaces reqwest's name resolution: the client connects only to the socket addresses yourResolveimplementation returns, for every request. Hosts written as IP literals are not resolved, so checkUrl::host()forHost::Ipv4andHost::Ipv6before sending, and callno_proxy()so an environment proxy does not become the real destination.Ipv6Addr::to_ipv4_mapped and address classesto_ipv4_mapped()turns::ffff:127.0.0.1back into127.0.0.1.Ipv4Addrhas stableis_private,is_loopback,is_link_local,is_multicast,is_broadcastandis_documentation;is_globalis still unstable, so CGNAT and0.0.0.0/8need explicit checks and IPv6 is limited to global unicast2000::/3.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-918
- OWASP Top 10:2025 — A01:2025 Broken Access Control
- OWASP API Security Top 10 — API7:2023 Server Side Request Forgery
- D3-OTF Outbound Traffic Filtering — This guide's outbound network restriction limits which destinations the service or host can reach.
Step-by-Step Attack Flow
A user supplies a URL
An Axum handler extracts FetchRequest from JSON and passes url to reqwest::get or a default Client.
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.
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.
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
// 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() })))
}
// 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
- ✓Add a Semgrep rule that flags request data reaching
reqwest::get,Client::get,Client::postorhyperclients, and review every hit. - ✓Build one shared client with
dns_resolverreturning only public addresses afterto_ipv4_mapped(), and reject IP-literal hosts that fail the same check. - ✓Set
redirect(Policy::none()),no_proxy(),connect_timeoutandtimeout, and allow onlyhttpandhttpsURLs. - ✓Read responses chunk by chunk with a fixed byte limit instead of
text()orbytes(). - ✓Enforce IMDSv2 with a hop limit of 1 and restrict egress with a network policy or firewall as a second layer.