use crate::urlvalidation::validate_url;
use std::fmt;
use std::time::Duration;
const MAX_REDIRECT_HOPS: u8 = 5;
#[derive(Debug)]
pub enum SafeHttpError {
Blocked(anyhow::Error),
TooManyRedirects { max: u8 },
BadRedirect(String),
Request(Box<ureq::Error>),
}
impl fmt::Display for SafeHttpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SafeHttpError::Blocked(e) => write!(f, "blocked by URL validation: {e}"),
SafeHttpError::TooManyRedirects { max } => {
write!(f, "too many redirects (max {max}), possible redirect loop")
}
SafeHttpError::BadRedirect(msg) => write!(f, "bad redirect: {msg}"),
SafeHttpError::Request(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for SafeHttpError {}
pub fn get(
url: &str,
strict_https: bool,
timeout: Duration,
) -> Result<ureq::Response, SafeHttpError> {
follow(url, strict_https, |agent, u| {
agent.get(u).timeout(timeout).call().map_err(Box::new)
})
}
pub fn post_json(
url: &str,
strict_https: bool,
body: &str,
user_agent: &str,
) -> Result<ureq::Response, SafeHttpError> {
follow(url, strict_https, |agent, u| {
agent
.post(u)
.set("Content-Type", "application/json")
.set("User-Agent", user_agent)
.send_string(body)
.map_err(Box::new)
})
}
fn follow(
url: &str,
strict_https: bool,
attempt: impl Fn(&ureq::Agent, &str) -> Result<ureq::Response, Box<ureq::Error>>,
) -> Result<ureq::Response, SafeHttpError> {
validate_url(url, strict_https).map_err(SafeHttpError::Blocked)?;
let agent = ureq::AgentBuilder::new().redirects(0).build();
let mut current = url.to_string();
for hop in 0..=MAX_REDIRECT_HOPS {
let resp = attempt(&agent, ¤t).map_err(SafeHttpError::Request)?;
if !(300..400).contains(&resp.status()) {
return Ok(resp);
}
let Some(location) = resp.header("location").map(str::to_string) else {
return Ok(resp);
};
if hop == MAX_REDIRECT_HOPS {
return Err(SafeHttpError::TooManyRedirects {
max: MAX_REDIRECT_HOPS,
});
}
current = resolve_and_validate_redirect(¤t, &location, strict_https)?;
}
unreachable!("the loop above always returns before its final iteration completes")
}
fn resolve_and_validate_redirect(
current: &str,
location: &str,
strict_https: bool,
) -> Result<String, SafeHttpError> {
let base = url::Url::parse(current)
.map_err(|e| SafeHttpError::BadRedirect(format!("current URL became unparseable: {e}")))?;
let next = base.join(location).map_err(|e| {
SafeHttpError::BadRedirect(format!("bad redirect Location '{location}': {e}"))
})?;
let next = next.to_string();
validate_url(&next, strict_https).map_err(SafeHttpError::Blocked)?;
Ok(next)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redirect_target_rejected_like_an_initial_private_ip_url() {
let metadata_url = "http://169.254.169.254/latest/meta-data/";
assert!(
validate_url(metadata_url, false).is_err(),
"sanity check failed: validate_url should reject this as an initial URL too"
);
let result =
resolve_and_validate_redirect("https://looks-benign.example.com/", metadata_url, false);
match result {
Err(SafeHttpError::Blocked(_)) => {}
other => panic!(
"redirect to the cloud metadata address must be blocked exactly like \
an initial URL would be, got: {other:?}"
),
}
}
#[test]
fn redirect_target_rejected_for_rfc1918_private_range() {
for target in [
"http://10.0.0.1/",
"http://172.16.0.1/",
"http://192.168.1.1/",
"http://127.0.0.1:6379/",
] {
let result = resolve_and_validate_redirect("https://example.com/", target, false);
assert!(
matches!(result, Err(SafeHttpError::Blocked(_))),
"expected redirect to {target} to be blocked, got: {result:?}"
);
}
}
#[test]
fn redirect_target_resolved_relative_to_current_url() {
let resolved = resolve_and_validate_redirect("https://example.com/a/b", "/c", false)
.expect("relative redirect to a public path should resolve and validate");
assert_eq!(resolved, "https://example.com/c");
}
#[test]
fn redirect_to_another_public_host_is_allowed() {
let resolved = resolve_and_validate_redirect(
"https://example.com/",
"https://other-public-host.example.org/landing",
false,
)
.expect("redirect to a different public host should be allowed");
assert_eq!(resolved, "https://other-public-host.example.org/landing");
}
}