Frontend Labs
⋯

48. Outbound requests: timeouts, retries, idempotency, and an SSRF guard

Implements: HTTPS Ch 21 and Ch 22; Security in Depth Ch 33.

48.1 Retries with backoff, jitter, and an idempotency key

A retry is only safe if repeating the request cannot do the work twice. GET is safe by definition. A POST /payments is safe only if the server deduplicates by an idempotency key that the client generates once, before the first attempt. The demo below simulates the worst case: the server charges the card, then the response is lost (here, a 503 sent after the charge).

// src/bin/retries.rs
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::{routing::post, Router};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;

#[derive(Default)]
struct Ledger { charges: u32, attempts: u32, seen: HashMap<String, String> }

async fn pay(State(l): State<Arc<Mutex<Ledger>>>, headers: HeaderMap) -> (StatusCode, String) {
    let Some(key) = headers.get("idempotency-key").and_then(|v| v.to_str().ok()).map(String::from) else {
        return (StatusCode::BAD_REQUEST, "Idempotency-Key required".into());
    };
    let mut l = l.lock().unwrap();
    l.attempts += 1;
    if let Some(prev) = l.seen.get(&key) {
        return (StatusCode::CREATED, prev.clone());          // replay the stored result; no second charge
    }
    l.charges += 1;
    let receipt = format!("receipt-{}", l.charges);
    l.seen.insert(key, receipt.clone());
    if l.attempts == 1 {
        return (StatusCode::SERVICE_UNAVAILABLE, "lost response".into()); // charged, but the client sees a failure
    }
    (StatusCode::CREATED, receipt)
}

fn backoff(attempt: u32) -> Duration {
    let cap_ms = 2_000u64;
    let exp_ms = 100u64.saturating_mul(1 << attempt.min(10)).min(cap_ms);
    Duration::from_millis(rand::random_range(0..=exp_ms))     // "full jitter": avoid synchronized retry storms
}

fn retryable(status: StatusCode) -> bool {
    matches!(status, StatusCode::TOO_MANY_REQUESTS | StatusCode::BAD_GATEWAY | StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT)
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let ledger = Arc::new(Mutex::new(Ledger::default()));
    let app = Router::new().route("/payments", post(pay)).with_state(Arc::clone(&ledger));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
    let url = format!("http://{}/payments", listener.local_addr()?);
    tokio::spawn(async move { axum::serve(listener, app).await });

    let client = reqwest::Client::builder()
        .connect_timeout(Duration::from_secs(2))
        .timeout(Duration::from_secs(5))                       // total per-attempt deadline
        .build()?;
    let key = format!("{:032x}", rand::random::<u128>());       // generated ONCE, reused by every attempt

    let mut attempt = 0;
    let receipt = loop {
        let result = client.post(&url).header("Idempotency-Key", &key).body("amount=500").send().await;
        match result {
            Ok(r) if r.status().is_success() => break r.text().await?,
            Ok(r) if retryable(r.status()) && attempt < 4 => println!("attempt {attempt}: {}, retrying", r.status()),
            Err(e) if (e.is_timeout() || e.is_connect()) && attempt < 4 => println!("attempt {attempt}: {e}, retrying"),
            Ok(r) => anyhow::bail!("giving up: {}", r.status()),
            Err(e) => return Err(e.into()),
        }
        tokio::time::sleep(backoff(attempt)).await;
        attempt += 1;
    };
    let l = ledger.lock().unwrap();
    println!("{receipt}; server saw {} attempts and charged {} time(s)", l.attempts, l.charges);
    Ok(())
}
attempt 0: 503 Service Unavailable, retrying
receipt-1; server saw 2 attempts and charged 1 time(s)

Without the key, the retry would have charged the card twice. In a real service, store the key with the result in the same database transaction as the charge, and expire keys after a retention period. Add a retry budget (for example, retries may be at most 10% of requests) so a struggling dependency does not receive three times its normal load. That is the retry-storm failure described in HTTPS Ch 21.

48.2 An SSRF guard that survives DNS rebinding

A server that fetches user-supplied URLs (webhooks, link previews, image imports) can be pointed at http://169.254.169.254/ (cloud credentials), http://localhost:6379/ (an internal Redis), or a private network. Checking the hostname string is not enough. evil.example can resolve to 127.0.0.1, or resolve to a public IP during your check and a private one when the HTTP client connects (DNS rebinding). The fix: resolve once, check every address, and connect only to the address you checked.

// src/bin/safefetch.rs
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;

fn is_forbidden(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => {
            let o = v4.octets();
            v4.is_private() || v4.is_loopback() || v4.is_link_local()  // 10/8 172.16/12 192.168/16 127/8 169.254/16
                || v4.is_unspecified() || v4.is_broadcast() || v4.is_documentation() || v4.is_multicast()
                || o[0] == 0                                           // 0.0.0.0/8 ("this network")
                || (o[0] == 100 && (o[1] & 0xC0) == 64)                // 100.64/10 carrier-grade NAT
                || (o[0] == 198 && (o[1] & 0xFE) == 18)                // 198.18/15 benchmarking
        }
        IpAddr::V6(v6) => {
            let s = v6.segments();
            v6.is_loopback() || v6.is_unspecified() || v6.is_multicast()
                || (s[0] & 0xFE00) == 0xFC00                           // fc00::/7 unique local
                || (s[0] & 0xFFC0) == 0xFE80                           // fe80::/10 link local
                || v6.to_ipv4_mapped().is_some_and(|v4| is_forbidden(IpAddr::V4(v4))) // ::ffff:127.0.0.1
        }
    }
}

async fn safe_get(raw_url: &str, max_bytes: usize) -> anyhow::Result<String> {
    let url = reqwest::Url::parse(raw_url)?;
    anyhow::ensure!(matches!(url.scheme(), "http" | "https"), "scheme {} not allowed", url.scheme());
    anyhow::ensure!(url.username().is_empty() && url.password().is_none(), "credentials in URL not allowed");
    let host = url.host_str().ok_or_else(|| anyhow::anyhow!("no host"))?;
    let port = url.port_or_known_default().unwrap_or(80);
    let bare = host.trim_start_matches('[').trim_end_matches(']');   // IPv6 literals come bracketed

    // 1. Resolve ONCE, with a deadline.
    let addrs: Vec<SocketAddr> = tokio::time::timeout(Duration::from_secs(2), tokio::net::lookup_host((bare, port)))
        .await??.collect();
    // 2. EVERY address must be allowed: attackers return [public, private] and hope you check only the first.
    anyhow::ensure!(!addrs.is_empty(), "{host} did not resolve");
    if let Some(bad) = addrs.iter().find(|a| is_forbidden(a.ip())) {
        anyhow::bail!("{host} resolves to forbidden address {}", bad.ip());
    }
    // 3. PIN the connection to the vetted address, so a second DNS answer cannot redirect it.
    let client = reqwest::Client::builder()
        .resolve(bare, addrs[0])
        .redirect(reqwest::redirect::Policy::none())   // a redirect to http://127.0.0.1 would skip all checks
        .connect_timeout(Duration::from_secs(3))
        .timeout(Duration::from_secs(10))
        .build()?;
    let mut resp = client.get(url).send().await?;
    anyhow::ensure!(resp.status().is_success(), "status {}", resp.status());

    // 4. Cap the body while streaming. Content-Length can be missing or false.
    let mut body = Vec::new();
    while let Some(chunk) = resp.chunk().await? {
        anyhow::ensure!(body.len() + chunk.len() <= max_bytes, "response larger than {max_bytes} bytes");
        body.extend_from_slice(&chunk);
    }
    Ok(String::from_utf8_lossy(&body).into_owned())
}

#[tokio::main]
async fn main() {
    for url in [
        "http://169.254.169.254/latest/meta-data/",   // cloud metadata
        "http://localhost:6379/",                      // resolves to loopback
        "http://[::ffff:127.0.0.1]/",                  // IPv4-mapped IPv6 loopback
        "http://0x7f000001/",                          // 127.0.0.1 in hex: the URL parser normalizes it
        "http://10.1.2.3/admin",                       // private network
        "file:///etc/passwd",                          // wrong scheme
        "http://user:pw@example.com/",                 // credentials in the URL
    ] {
        match safe_get(url, 1 << 20).await {
            Ok(body) => println!("ALLOWED {url} ({} bytes)", body.len()),
            Err(e) => println!("blocked {url:<42} {e}"),
        }
    }
}
blocked http://169.254.169.254/latest/meta-data/   169.254.169.254 resolves to forbidden address 169.254.169.254
blocked http://localhost:6379/                     localhost resolves to forbidden address ::1
blocked http://[::ffff:127.0.0.1]/                 [::ffff:7f00:1] resolves to forbidden address ::ffff:127.0.0.1
blocked http://0x7f000001/                         127.0.0.1 resolves to forbidden address 127.0.0.1
blocked http://10.1.2.3/admin                      10.1.2.3 resolves to forbidden address 10.1.2.3
blocked file:///etc/passwd                         scheme file not allowed
blocked http://user:pw@example.com/                credentials in URL not allowed

Note the 0x7f000001 line. The WHATWG URL parser that reqwest::Url uses normalizes hex, octal, and short-form IPv4 hosts before you see them, so the check runs on the real address. Hand-written string checks such as host.starts_with("127.") miss all of these encodings. This is why every SSRF guide says to parse first and then check addresses, never strings.

A production version should also apply the check to every redirect hop (implement redirect::Policy::custom), and run the fetcher in a network segment with egress firewall rules as the second layer (Security in Depth Ch 8).


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36