use reqwest::redirect::Policy;
use url::Url;
use crate::config::FetchConfig;
use crate::error::{FetchError, SafeUrl};
use crate::url_policy::check_url;
pub(crate) fn check_redirect(
previous: &[Url],
next: &Url,
config: &FetchConfig,
) -> Result<(), FetchError> {
let from = previous.last();
let from_str = from.map_or_else(|| "(origin)".to_string(), ToString::to_string);
let from_safe = SafeUrl::new(&from_str);
let to_safe = SafeUrl::new(next.as_str());
if previous.len() > config.max_redirects() {
return Err(FetchError::RedirectRefused {
from: from_safe,
to: to_safe,
reason: format!("exceeded max redirects ({})", config.max_redirects()),
});
}
if let Some(from) = from
&& from.scheme() == "https"
&& next.scheme() == "http"
{
return Err(FetchError::RedirectRefused {
from: from_safe,
to: to_safe,
reason: "refusing https to http downgrade".to_string(),
});
}
if let Err(err) = check_url(next.as_str(), config) {
return Err(FetchError::RedirectRefused {
from: from_safe,
to: to_safe,
reason: err.model_facing(),
});
}
Ok(())
}
#[must_use]
pub(crate) fn redirect_policy(config: FetchConfig) -> Policy {
Policy::custom(move |attempt| {
match check_redirect(attempt.previous(), attempt.url(), &config) {
Ok(()) => attempt.follow(),
Err(err) => attempt.error(err),
}
})
}
#[cfg(test)]
mod tests {
use url::Url;
use super::check_redirect;
use crate::config::FetchConfig;
use crate::error::FetchError;
fn url(s: &str) -> Url {
Url::parse(s).expect("test url must parse")
}
#[test]
fn refuses_https_to_http_downgrade() {
let cfg = FetchConfig::builder()
.allow_http(true)
.build()
.expect("valid config");
let previous = [url("https://example.com/")];
let next = url("http://example.com/");
let err = check_redirect(&previous, &next, &cfg)
.expect_err("an https to http downgrade must be refused");
match err {
FetchError::RedirectRefused { reason, .. } => {
assert!(reason.contains("downgrade"), "reason was: {reason}");
}
other => panic!("expected RedirectRefused, got {other:?}"),
}
}
#[test]
fn refuses_redirect_to_ip_literal() {
let cfg = FetchConfig::builder()
.allow_http(true)
.build()
.expect("valid config");
let previous = [url("http://example.com/")];
let next = url("http://127.0.0.1/");
let err = check_redirect(&previous, &next, &cfg)
.expect_err("a redirect to an ip literal must be refused");
assert!(matches!(err, FetchError::RedirectRefused { .. }));
}
#[test]
fn refuses_redirect_targets_by_url_policy() {
let cfg = FetchConfig::default();
let previous = [url("https://example.com/")];
for next in [
"https://user:pass@example.com/",
"https://example.com:8080/",
"ftp://example.com/",
] {
let err = check_redirect(&previous, &url(next), &cfg)
.expect_err("a policy-violating redirect target must be refused");
assert!(
matches!(err, FetchError::RedirectRefused { .. }),
"for {next}"
);
}
}
#[test]
fn refuses_encoded_ip_literal_redirect_targets() {
let cfg = FetchConfig::builder()
.allow_http(true)
.build()
.expect("valid config");
let previous = [url("http://example.com/")];
for next in [
"http://0177.0.0.1/", "http://2130706433/", "http://127.1/", "http://[::1]/", "http://[::ffff:127.0.0.1]/", "http://[::127.0.0.1]/", ] {
let err = check_redirect(&previous, &url(next), &cfg)
.expect_err("an encoded IP-literal redirect target must be refused");
assert!(
matches!(err, FetchError::RedirectRefused { .. }),
"for {next}"
);
}
}
#[test]
fn redirect_cap_boundaries() {
let cfg = FetchConfig::builder()
.max_redirects(2)
.build()
.expect("valid config");
let at_cap = [url("https://a.example/"), url("https://b.example/")];
assert!(
check_redirect(&at_cap, &url("https://c.example/"), &cfg).is_ok(),
"exactly the cap must be followed"
);
let over_cap = [
url("https://a.example/"),
url("https://b.example/"),
url("https://c.example/"),
];
let err = check_redirect(&over_cap, &url("https://d.example/"), &cfg)
.expect_err("one past the cap must be refused");
match err {
FetchError::RedirectRefused { reason, .. } => {
assert!(reason.contains("max redirects"), "reason was: {reason}");
}
other => panic!("expected RedirectRefused, got {other:?}"),
}
}
#[test]
fn zero_cap_refuses_first_redirect() {
let cfg = FetchConfig::builder()
.max_redirects(0)
.build()
.expect("valid config");
let previous = [url("https://a.example/")];
let err = check_redirect(&previous, &url("https://b.example/"), &cfg)
.expect_err("a zero cap must refuse the first redirect");
assert!(matches!(err, FetchError::RedirectRefused { .. }));
}
#[test]
fn allows_ordinary_https_hop() {
let cfg = FetchConfig::default();
let previous = [url("https://example.com/")];
let next = url("https://example.com/next");
assert!(check_redirect(&previous, &next, &cfg).is_ok());
}
}