What are the trade-offs between reqwest::redirect::Policy::limited and none for HTTP redirect handling?

reqwest::redirect::Policy::limited and Policy::none represent two different approaches to handling HTTP redirects: limited(n) automatically follows up to n redirects before returning an error, while none disables automatic redirect following entirely, returning redirect responses directly to the caller for manual handling. The trade-off centers on convenience versus control: limited handles the common case of following redirects transparently but can hide security issues, loops, and response details, while none gives you full visibility into redirect chains but requires manual redirect handling. The default policy is limited(10), reflecting HTTP conventions that most redirects are safe to follow, but security-sensitive applications should consider none or a custom policy to prevent open redirect vulnerabilities, SSRF attacks, and credential leakage across domains. Understanding these trade-offs helps you choose the right policy for your application's security posture and redirect handling needs.

Default Redirect Behavior

use reqwest::Client;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // By default, reqwest follows up to 10 redirects
    let client = Client::new();
    
    // This will automatically follow redirects
    let response = client
        .get("https://httpbin.org/redirect/3")
        .send()
        .await?;
    
    // Response is the FINAL destination after redirects
    println!("Final URL: {:?}", response.url());
    println!("Status: {}", response.status());
    
    // The redirect chain was followed automatically
    // You get the final response, not the redirect
    
    Ok(())
}

By default, reqwest follows up to 10 redirects automatically.

Policy::limited for Automatic Redirect Following

use reqwest::{Client, redirect::Policy};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Policy::limited(n) follows up to n redirects
    let client = Client::builder()
        .redirect(Policy::limited(5))  // Follow at most 5 redirects
        .build()?;
    
    // If there are 3 redirects, we get the final response
    let response = client
        .get("https://httpbin.org/redirect/3")
        .send()
        .await?;
    
    println!("Status after redirects: {}", response.status());
    
    // If there are 10 redirects with limit of 5, we get an error
    let result = client
        .get("https://httpbin.org/redirect/10")
        .send()
        .await;
    
    match result {
        Ok(_) => println!("Success"),
        Err(e) => {
            if e.is_redirect() {
                println!("Too many redirects: {}", e);
            }
        }
    }
    
    Ok(())
}

Policy::limited(n) follows redirects up to a maximum count, then errors.

Policy::none for Manual Redirect Handling

use reqwest::{Client, redirect::Policy, StatusCode};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Policy::none disables automatic redirects
    let client = Client::builder()
        .redirect(Policy::none())
        .build()?;
    
    // Now we get redirect responses directly
    let response = client
        .get("https://httpbin.org/redirect/1")
        .send()
        .await?;
    
    // Instead of following, we see the redirect status
    println!("Status: {}", response.status());  // 302 Found
    
    // We can inspect the redirect location
    if let Some(location) = response.headers().get("Location") {
        println!("Redirect to: {:?}", location);
    }
    
    // We decide whether to follow
    if response.status().is_redirection() {
        let location = response.headers()
            .get("Location")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("");
        
        // Manually follow the redirect
        let final_response = client
            .get(location)
            .send()
            .await?;
        
        println!("Final status: {}", final_response.status());
    }
    
    Ok(())
}

Policy::none() returns redirect responses directly without following.

Security Implications of Following Redirects

use reqwest::{Client, redirect::Policy, Url};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Security issue: automatic redirects can leak credentials
    
    // Scenario: Request with Authorization header
    let client = Client::builder()
        .redirect(Policy::limited(10))
        .build()?;
    
    // WARNING: This could leak credentials!
    // If example.com redirects to attacker.com, the Authorization
    // header might be forwarded to the attacker's server
    
    // With Policy::limited, we follow automatically without knowing
    // where the redirect goes
    
    // Safer approach: Policy::none with validation
    let safe_client = Client::builder()
        .redirect(Policy::none())
        .build()?;
    
    // Now we can inspect redirects before following
    let response = safe_client
        .get("https://example.com/api")
        .bearer_auth("secret-token")
        .send()
        .await?;
    
    if response.status().is_redirection() {
        let location = response.headers()
            .get("Location")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("");
        
        let redirect_url = Url::parse(location)?;
        
        // Only follow if redirect stays on same host
        if redirect_url.host_str() == response.url().host_str() {
            // Safe to follow with credentials
            let _final = safe_client
                .get(redirect_url)
                .bearer_auth("secret-token")
                .send()
                .await?;
        } else {
            // Cross-origin redirect - don't send credentials
            let _final = safe_client
                .get(redirect_url)
                .send()  // No auth header
                .await?;
        }
    }
    
    Ok(())
}

Automatic redirects can leak credentials to cross-origin destinations.

Preventing Open Redirect Attacks

use reqwest::{Client, redirect::Policy, Url};
 
// Open redirect: attacker can redirect your client to any URL
// This is especially dangerous in server-side applications
 
async fn fetch_user_data(url: &str) -> Result<String, Box<dyn std::error::Error>> {
    // UNSAFE: User-controlled URL with automatic redirects
    let client = Client::new();
    let response = client.get(url).send().await?;
    
    // If attacker controls the URL and it redirects to internal network:
    // http://attacker.com/redirect?url=http://localhost:8080/admin
    // We might access internal resources!
    
    Ok(response.text().await?)
}
 
// Safer: Validate redirects
async fn fetch_user_data_safe(url: &str) -> Result<String, Box<dyn std::error::Error>> {
    let client = Client::builder()
        .redirect(Policy::none())
        .build()?;
    
    let allowed_hosts = ["api.example.com", "cdn.example.com"];
    
    let mut current_url = Url::parse(url)?;
    let mut redirects = 0;
    let max_redirects = 5;
    
    loop {
        let response = client.get(current_url.clone()).send().await?;
        
        if !response.status().is_redirection() {
            return Ok(response.text().await?);
        }
        
        redirects += 1;
        if redirects > max_redirects {
            return Err("Too many redirects".into());
        }
        
        let location = response.headers()
            .get("Location")
            .and_then(|v| v.to_str().ok())
            .ok_or("Missing Location header")?;
        
        let next_url = current_url.join(location)?;
        
        // Validate host
        let host = next_url.host_str()
            .ok_or("Invalid URL")?;
        
        if !allowed_hosts.contains(&host) {
            return Err(format!("Redirect to disallowed host: {}", host).into());
        }
        
        current_url = next_url;
    }
}
 
#[tokio::main]
async fn main() {
    // Demonstration of safe redirect handling
}

Policy::none() allows validating redirect destinations before following.

Handling Redirect Loops

use reqwest::{Client, redirect::Policy};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Policy::limited detects loops by counting redirects
    // But it only catches loops within the redirect limit
    
    let client = Client::builder()
        .redirect(Policy::limited(10))
        .build()?;
    
    // A -> B -> A -> B -> ... (infinite loop)
    // With limited(10), we get an error after 10 redirects
    
    // With Policy::none, we handle loops ourselves
    let manual_client = Client::builder()
        .redirect(Policy::none())
        .build()?;
    
    // We can track visited URLs to detect loops
    use std::collections::HashSet;
    
    async fn fetch_with_loop_detection(
        client: &Client,
        url: &str,
    ) -> Result<String, Box<dyn std::error::Error>> {
        let mut visited = HashSet::new();
        let mut current = url.to_string();
        
        loop {
            if !visited.insert(current.clone()) {
                return Err("Redirect loop detected".into());
            }
            
            let response = client.get(&current).send().await?;
            
            if !response.status().is_redirection() {
                return Ok(response.text().await?);
            }
            
            current = response.headers()
                .get("Location")
                .and_then(|v| v.to_str().ok())
                .ok_or("Missing Location")?
                .to_string();
        }
    }
    
    Ok(())
}

Policy::limited catches loops eventually; Policy::none enables custom loop detection.

Inspecting Redirect History

use reqwest::{Client, redirect::Policy};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // With Policy::limited, we lose visibility into redirect chain
    
    let client = Client::builder()
        .redirect(Policy::limited(5))
        .build()?;
    
    let response = client
        .get("https://httpbin.org/redirect/3")
        .send()
        .await?;
    
    // We only see the final URL
    println!("Final URL: {}", response.url());
    // But we don't know what redirects happened
    
    // With Policy::none, we track the entire chain
    let manual_client = Client::builder()
        .redirect(Policy::none())
        .build()?;
    
    let mut url = "https://httpbin.org/redirect/3".to_string();
    let mut redirect_chain = vec![url.clone()];
    
    loop {
        let response = manual_client
            .get(&url)
            .send()
            .await?;
        
        if !response.status().is_redirection() {
            println!("Redirect chain: {:?}", redirect_chain);
            println!("Final status: {}", response.status());
            break;
        }
        
        url = response.headers()
            .get("Location")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("")
            .to_string();
        
        redirect_chain.push(url.clone());
        
        if redirect_chain.len() > 10 {
            println!("Too many redirects");
            break;
        }
    }
    
    Ok(())
}

Policy::none preserves visibility into the redirect chain.

Redirect Response Types

use reqwest::{Client, redirect::Policy, StatusCode};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .redirect(Policy::none())
        .build()?;
    
    // Different redirect status codes have different semantics:
    // 301 Moved Permanently - POST becomes GET
    // 302 Found - POST becomes GET (historical)
    // 303 See Other - POST becomes GET
    // 307 Temporary Redirect - preserves method
    // 308 Permanent Redirect - preserves method
    
    let response = client
        .get("https://httpbin.org/redirect-to?url=/get")
        .send()
        .await?;
    
    match response.status() {
        StatusCode::MOVED_PERMANENTLY => {
            println!("301: Resource moved permanently");
            // For POST requests, standard says change to GET
        }
        StatusCode::FOUND => {
            println!("302: Found (temporary redirect)");
            // For POST requests, standard says change to GET
        }
        StatusCode::SEE_OTHER => {
            println!("303: See Other");
            // Always use GET for the redirect
        }
        StatusCode::TEMPORARY_REDIRECT => {
            println!("307: Temporary Redirect");
            // Preserve the original method (POST stays POST)
        }
        StatusCode::PERMANENT_REDIRECT => {
            println!("308: Permanent Redirect");
            // Preserve the original method
        }
        _ => {}
    }
    
    // Policy::limited handles these automatically
    // Policy::none lets you handle them according to your needs
    
    Ok(())
}

Different redirect codes have different semantics for method handling.

Custom Redirect Policies

use reqwest::{Client, redirect::Policy};
use url::Url;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // For complex needs, implement a custom policy
    // Policy::custom takes a closure
    
    let client = Client::builder()
        .redirect(Policy::custom(|attempt| {
            let url = attempt.url();
            
            // Don't follow redirects to non-HTTPS URLs
            if url.scheme() != "https" {
                return attempt.stop();
            }
            
            // Don't follow redirects to private IP ranges
            if let Some(host) = url.host_str() {
                if host.starts_with("192.168.") 
                    || host.starts_with("10.")
                    || host.starts_with("172.16.") {
                    return attempt.stop();
                }
            }
            
            // Follow up to 10 redirects
            if attempt.previous().len() >= 10 {
                return attempt.too_many_redirects();
            }
            
            // Only follow same-host redirects
            let previous = attempt.previous();
            if let Some(first) = previous.first() {
                if first.host_str() != url.host_str() {
                    return attempt.stop();
                }
            }
            
            attempt.follow()
        }))
        .build()?;
    
    // Now redirects are controlled by our policy
    println!("Custom redirect policy configured");
    
    Ok(())
}

Policy::custom enables fine-grained redirect control.

Comparison Table

fn main() {
    // | Aspect            | Policy::limited(n)        | Policy::none()            |
    // |-------------------|---------------------------|---------------------------|
    // | Follows redirects | Yes, up to n              | No                        |
    // | Visibility        | Final response only       | Each redirect visible     |
    // | Security          | May leak credentials      | Full control              |
    // | Loop detection    | After n redirects         | Manual                    |
    // | Host validation   | None                      | Manual                    |
    // | Code complexity   | Simple                    | More code required        |
    // | Use case          | General API calls         | Security-sensitive apps   |
    
    println!("Comparison documented above");
}

Synthesis

Policy comparison:

Scenario Recommended Policy
Public API, no credentials Policy::limited(10) (default)
Authenticated requests Policy::none() or custom
User-provided URLs Policy::none() with validation
Internal service calls Policy::limited(n) if trusted
Following redirect chains Policy::none() for visibility
Simple GET requests Policy::limited(n)
POST/PUT with redirects Custom or Policy::none()

Security considerations:

Risk Policy::limited Policy::none
Credential leakage Possible Preventable
SSRF via redirect Possible Preventable
Open redirect abuse Possible Preventable
Redirect loops Detected after n Manual detection
Cross-origin redirects Followed blindly Can validate

Key insight: The choice between Policy::limited and Policy::none is a choice between convenience and control. Policy::limited(n) handles the common case automatically—most redirects are benign, and automatically following them simplifies application code. But this convenience comes at the cost of visibility and security: credentials can leak across origins, user-controlled URLs can redirect to internal networks, and redirect chains are hidden from the application. Policy::none() inverts this trade-off: you write more code to handle redirects, but you gain full control over every redirect decision. The right choice depends on your threat model: for public APIs without authentication, limited is usually fine; for authenticated requests or user-provided URLs, none with validation is safer. The Policy::custom option provides a middle ground, allowing you to implement domain-specific rules while still delegating the mechanical aspects of redirect following to the library. In practice, most applications should use limited for internal trusted APIs and none or custom policies for external, untrusted, or authenticated requests.