
Preventing SSRF in Rust: Safely Fetching Remote Resources
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:
- Parse and validate the URL before any request
- Restrict schemes to a small allowlist
- Resolve the hostname and reject private or special-use IPs
- Block redirects to disallowed destinations
- Limit request size, timeouts, and methods
- Avoid sending sensitive headers to untrusted hosts
- 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/for127.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:
- user submits
https://example.com/image - server returns
302 Location: http://169.254.169.254/latest/meta-data/ - 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:
- Parse the input URL
- Check the scheme
- Resolve the hostname
- Reject disallowed IPs
- Send the request with strict client settings
- Re-check redirects if enabled
- 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
| Control | What it blocks | Tradeoff |
|---|---|---|
| Scheme allowlist | file://, ftp://, custom handlers | May reject legitimate integrations |
| IP range filtering | Loopback, private, link-local targets | Requires careful IPv4/IPv6 handling |
| Redirect disabling | Redirect-based bypasses | Some sites rely on redirects |
| DNS resolution before request | DNS rebinding and hidden private IPs | More implementation complexity |
| Request timeout | Slow-loris style resource exhaustion | May cut off slow but valid servers |
| Response size limit | Large downloads and memory pressure | Must 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.1http://localhosthttp://169.254.169.254http://[::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.
