diff --git a/src/http/mod.rs b/src/http/mod.rs index 619459a..58486cc 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -20,8 +20,9 @@ use flate2::read::GzDecoder; use futures_util::stream; use http_body_util::BodyExt; use reqwest::header::{ - ACCEPT, ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, - LOCATION, RANGE, RETRY_AFTER, USER_AGENT, WWW_AUTHENTICATE, + ACCEPT, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, COOKIE, HeaderMap, + HeaderName, HeaderValue, LOCATION, PROXY_AUTHORIZATION, RANGE, RETRY_AFTER, USER_AGENT, + WWW_AUTHENTICATE, }; use reqwest::{Body, Client, Method, RequestBuilder, Response, StatusCode}; use sha2::{Digest as _, Sha256}; @@ -199,6 +200,10 @@ pub async fn execute(cli: &Cli) -> Result { let mut timing = AttemptTiming::start(); let attempt_result = loop { let mut attempt_headers = headers.clone(); + let auth_allowed = same_origin(&url, &request_url); + if !auth_allowed { + strip_cross_origin_sensitive_headers(&mut attempt_headers); + } if cli.verbose >= 2 && !cli.silent { print_request_metadata( cli, @@ -218,7 +223,7 @@ pub async fn execute(cli: &Cli) -> Result { { print_dns_debug(cli, dns); } - if let Some(config) = &aws_config { + if auth_allowed && let Some(config) = &aws_config { apply_aws_sigv4( cli, request_method.as_str(), @@ -236,7 +241,11 @@ pub async fn execute(cli: &Cli) -> Result { attempt_headers, request_body.clone(), cli, - None, + if auth_allowed { + RequestAuthorization::Cli + } else { + RequestAuthorization::None + }, )?; timing.set_dns( request_client @@ -299,6 +308,7 @@ pub async fn execute(cli: &Cli) -> Result { body: request_body.clone(), cli, redirect_statuses, + auth_allowed: same_origin(&url, &request_url), }, digest_credentials.as_ref(), ) @@ -1185,7 +1195,7 @@ fn build_request( mut headers: HeaderMap, body: RequestBody, cli: &Cli, - authorization: Option<&str>, + authorization: RequestAuthorization<'_>, ) -> Result { if let Some(len) = request_body_content_len(&body) && !headers.contains_key(CONTENT_LENGTH) @@ -1205,13 +1215,29 @@ fn build_request( req = req.body(request_body_to_reqwest_body(body)?); } - let mut authorization_headers = HeaderMap::new(); - apply_builder_authorization_headers(&mut authorization_headers, cli, authorization)?; - req = req.headers(authorization_headers); + match authorization { + RequestAuthorization::Cli => { + let mut authorization_headers = HeaderMap::new(); + apply_builder_authorization_headers(&mut authorization_headers, cli, None)?; + req = req.headers(authorization_headers); + } + RequestAuthorization::Digest(auth) => { + let mut authorization_headers = HeaderMap::new(); + apply_builder_authorization_headers(&mut authorization_headers, cli, Some(auth))?; + req = req.headers(authorization_headers); + } + RequestAuthorization::None => {} + } Ok(req) } +enum RequestAuthorization<'a> { + Cli, + Digest(&'a str), + None, +} + fn apply_builder_authorization_headers( headers: &mut HeaderMap, cli: &Cli, @@ -1252,6 +1278,7 @@ struct DigestRetryContext<'a> { body: RequestBody, cli: &'a Cli, redirect_statuses: Vec, + auth_allowed: bool, } async fn apply_digest_challenge( @@ -1265,6 +1292,9 @@ async fn apply_digest_challenge( if response.status() != StatusCode::UNAUTHORIZED { return Ok(response); } + if !context.auth_allowed { + return Ok(response); + } let challenge = digest::find_digest_challenge( response @@ -1303,7 +1333,7 @@ async fn apply_digest_challenge( context.headers, challenged_body, context.cli, - Some(&auth), + RequestAuthorization::Digest(&auth), )? .send() .await @@ -1932,6 +1962,20 @@ fn is_redirect_status(status: StatusCode) -> bool { ) } +fn same_origin(a: &Url, b: &Url) -> bool { + a.scheme() == b.scheme() + && a.host_str() + .zip(b.host_str()) + .is_some_and(|(a_host, b_host)| a_host.eq_ignore_ascii_case(b_host)) + && a.port_or_known_default() == b.port_or_known_default() +} + +fn strip_cross_origin_sensitive_headers(headers: &mut HeaderMap) { + headers.remove(AUTHORIZATION); + headers.remove(COOKIE); + headers.remove(PROXY_AUTHORIZATION); +} + fn redirected_request( mut method: Method, mut body: RequestBody, diff --git a/tests/integration.rs b/tests/integration.rs index 67beb13..0d88992 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -2754,6 +2754,128 @@ fn digest_auth_drain_is_bounded_for_large_challenge_body() { assert_eq!(requests[1].body_string(), "hello=world"); } +#[test] +fn cross_origin_redirect_strips_explicit_sensitive_headers() { + let target = TestServer::start(|req| { + if !req.header("authorization").is_empty() + || !req.header("cookie").is_empty() + || !req.header("proxy-authorization").is_empty() + { + return TestResponse::status(400, "Bad Request", "sensitive header leaked"); + } + TestResponse::ok("safe") + }); + let location = target.url.clone(); + let origin = TestServer::start(move |_| { + TestResponse::status(302, "Found", "").header("Location", &location) + }); + + let res = run_fetch(&[ + &origin.url, + "--header", + "Authorization: explicit", + "--header", + "Cookie: sid=secret", + "--header", + "Proxy-Authorization: proxy-secret", + ]); + assert_exit(&res, 0); + assert_eq!(res.stdout, "safe"); + + let requests = target.requests(); + assert_eq!(requests.len(), 1); + assert!(requests[0].header("authorization").is_empty()); + assert!(requests[0].header("cookie").is_empty()); + assert!(requests[0].header("proxy-authorization").is_empty()); +} + +#[test] +fn cross_origin_redirect_does_not_reapply_cli_auth() { + for args in [vec!["--basic", "user:pass"], vec!["--bearer", "token"]] { + let target = TestServer::start(|req| { + if !req.header("authorization").is_empty() { + return TestResponse::status(400, "Bad Request", "authorization leaked"); + } + TestResponse::ok("safe") + }); + let location = target.url.clone(); + let origin = TestServer::start(move |_| { + TestResponse::status(302, "Found", "").header("Location", &location) + }); + + let mut fetch_args = vec![origin.url.as_str()]; + fetch_args.extend(args); + let res = run_fetch(&fetch_args); + assert_exit(&res, 0); + assert_eq!(res.stdout, "safe"); + + let requests = target.requests(); + assert_eq!(requests.len(), 1); + assert!(requests[0].header("authorization").is_empty()); + } +} + +#[test] +fn cross_origin_redirect_does_not_sign_with_aws_auth() { + let target = TestServer::start(|req| { + if !req.header("authorization").is_empty() + || !req.header("x-amz-date").is_empty() + || !req.header("x-amz-security-token").is_empty() + { + return TestResponse::status(400, "Bad Request", "aws auth leaked"); + } + TestResponse::ok("safe") + }); + let location = target.url.clone(); + let origin = TestServer::start(move |_| { + TestResponse::status(302, "Found", "").header("Location", &location) + }); + + let res = run_fetch_opts( + FetchOpts { + env: vec![ + ("AWS_ACCESS_KEY_ID".to_string(), "1234".to_string()), + ("AWS_SECRET_ACCESS_KEY".to_string(), "5678".to_string()), + ("AWS_SESSION_TOKEN".to_string(), "session-token".to_string()), + ], + ..Default::default() + }, + &[&origin.url, "--aws-sigv4", "us-east-1/s3"], + ); + assert_exit(&res, 0); + assert_eq!(res.stdout, "safe"); + + let requests = target.requests(); + assert_eq!(requests.len(), 1); + assert!(requests[0].header("authorization").is_empty()); + assert!(requests[0].header("x-amz-date").is_empty()); + assert!(requests[0].header("x-amz-security-token").is_empty()); +} + +#[test] +fn cross_origin_redirect_does_not_retry_digest_auth() { + let target = TestServer::start(|req| { + if !req.header("authorization").is_empty() { + return TestResponse::status(400, "Bad Request", "digest auth leaked"); + } + TestResponse::status(401, "Unauthorized", "").header( + "WWW-Authenticate", + r#"Digest realm="test", nonce="abc123", qop="auth", algorithm="MD5""#, + ) + }); + let location = target.url.clone(); + let origin = TestServer::start(move |_| { + TestResponse::status(302, "Found", "").header("Location", &location) + }); + + let res = run_fetch(&[&origin.url, "--digest", "user:pass"]); + assert_exit(&res, 4); + + let requests = target.requests(); + assert_eq!(requests.len(), 1); + assert!(requests[0].header("authorization").is_empty()); +} + #[test] fn redirects_range_status_and_timeouts() { let server = TestServer::start(|req| match req.path.as_str() {