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(¤t).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.
