use std::collections::HashSet;
use http::{StatusCode, Uri, header};
use tracing::debug;
use crate::error::{Error, Result};
use crate::response::Response;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RedirectPolicy {
#[default]
Never,
Limited(u32),
All,
}
impl RedirectPolicy {
#[must_use]
pub fn standard() -> Self {
Self::Limited(10)
}
#[must_use]
pub fn should_follow(&self) -> bool {
!matches!(self, Self::Never)
}
#[must_use]
pub fn max_redirects(&self) -> Option<u32> {
match self {
Self::Never => Some(0),
Self::Limited(n) => Some(*n),
Self::All => None,
}
}
}
#[derive(Debug)]
pub enum RedirectAction {
Follow(Uri),
Stop,
}
#[derive(Debug, Clone)]
pub struct RedirectGuard {
max_redirects: u32,
count: u32,
visited: HashSet<String>,
allow_https_downgrade: bool,
allow_cross_origin: bool,
}
impl Default for RedirectGuard {
fn default() -> Self {
Self {
max_redirects: 10,
count: 0,
visited: HashSet::new(),
allow_https_downgrade: false,
allow_cross_origin: true,
}
}
}
impl RedirectGuard {
#[must_use]
pub fn new(max_redirects: u32) -> Self {
Self {
max_redirects,
..Default::default()
}
}
#[must_use]
pub fn forbid_https_downgrade(mut self) -> Self {
self.allow_https_downgrade = false;
self
}
#[must_use]
pub fn forbid_cross_origin(mut self) -> Self {
self.allow_cross_origin = false;
self
}
pub fn check_redirect(
&mut self,
response: &Response,
current_uri: &Uri,
) -> Result<RedirectAction> {
if !response.is_redirect() {
return Ok(RedirectAction::Stop);
}
if self.count >= self.max_redirects {
return Err(Error::TooManyRedirects {
count: self.count,
limit: self.max_redirects,
});
}
let location = response
.header(header::LOCATION.as_str())
.ok_or_else(|| Error::http("redirect response missing Location header"))?;
let target_uri = resolve_redirect_uri(current_uri, location)?;
let target_str = target_uri.to_string();
if self.visited.contains(&target_str) {
return Err(Error::http(format!(
"redirect loop detected: {} already visited",
target_str
)));
}
if !self.allow_https_downgrade
&& current_uri.scheme_str() == Some("https")
&& target_uri.scheme_str() == Some("http")
{
return Err(Error::http(format!(
"refusing HTTPS to HTTP downgrade: {} -> {}",
current_uri, target_uri
)));
}
if !self.allow_cross_origin {
let current_host = current_uri.host();
let target_host = target_uri.host();
if current_host != target_host {
return Err(Error::http(format!(
"refusing cross-origin redirect: {} -> {}",
current_host.unwrap_or("unknown"),
target_host.unwrap_or("unknown")
)));
}
}
self.visited.insert(target_str);
self.count += 1;
debug!(
"Following redirect {}/{}: {} -> {}",
self.count, self.max_redirects, current_uri, target_uri
);
Ok(RedirectAction::Follow(target_uri))
}
#[must_use]
pub fn redirect_count(&self) -> u32 {
self.count
}
}
fn resolve_redirect_uri(current: &Uri, location: &str) -> Result<Uri> {
if let Ok(uri) = location.parse::<Uri>() {
if uri.scheme().is_some() && uri.host().is_some() {
return Ok(uri);
}
}
if location.starts_with("//") {
let scheme = current.scheme_str().unwrap_or("https");
let full_url = format!("{}:{}", scheme, location);
return full_url
.parse()
.map_err(|_| Error::invalid_url(&full_url, "invalid redirect URL"));
}
if location.starts_with('/') {
let scheme = current.scheme_str().unwrap_or("https");
let authority = current.authority().map(|a| a.as_str()).unwrap_or("");
let full_url = format!("{}://{}{}", scheme, authority, location);
return full_url
.parse()
.map_err(|_| Error::invalid_url(&full_url, "invalid redirect URL"));
}
let scheme = current.scheme_str().unwrap_or("https");
let authority = current.authority().map(|a| a.as_str()).unwrap_or("");
let current_path = current.path();
let base_path = current_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
let full_url = format!("{}://{}{}/{}", scheme, authority, base_path, location);
full_url
.parse()
.map_err(|_| Error::invalid_url(&full_url, "invalid redirect URL"))
}
#[must_use]
pub fn should_remove_auth_on_redirect(from: &Uri, to: &Uri) -> bool {
from.host() != to.host()
}
#[must_use]
pub fn redirect_method_for_status(
status: StatusCode,
original_method: &http::Method,
) -> http::Method {
use http::Method;
match status.as_u16() {
301 | 302 => {
if *original_method == Method::POST {
Method::GET
} else {
original_method.clone()
}
}
303 => {
if *original_method == Method::HEAD {
Method::HEAD
} else {
Method::GET
}
}
307 | 308 => original_method.clone(),
_ => original_method.clone(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
#[test]
fn test_redirect_policy() {
assert!(!RedirectPolicy::Never.should_follow());
assert!(RedirectPolicy::Limited(5).should_follow());
assert!(RedirectPolicy::All.should_follow());
assert_eq!(RedirectPolicy::standard().max_redirects(), Some(10));
}
#[test]
fn test_resolve_absolute_redirect() {
let current: Uri = "https://example.com/page".parse().unwrap();
let target = resolve_redirect_uri(¤t, "https://other.com/new").unwrap();
assert_eq!(target.to_string(), "https://other.com/new");
}
#[test]
fn test_resolve_relative_redirect() {
let current: Uri = "https://example.com/foo/bar".parse().unwrap();
let target = resolve_redirect_uri(¤t, "/baz").unwrap();
assert_eq!(target.to_string(), "https://example.com/baz");
}
#[test]
fn test_redirect_method_change() {
use http::Method;
assert_eq!(
redirect_method_for_status(StatusCode::SEE_OTHER, &Method::POST),
Method::GET
);
assert_eq!(
redirect_method_for_status(StatusCode::TEMPORARY_REDIRECT, &Method::POST),
Method::POST
);
}
}