Skip to content

Commit 8df7fbc

Browse files
committed
validate axum redirect path to forbid accidental redirects to other domains
1 parent 140b5b9 commit 8df7fbc

File tree

2 files changed

+54
-8
lines changed

2 files changed

+54
-8
lines changed

src/web/crate_details.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ pub(crate) async fn crate_details_handler(
322322
return Ok(super::axum_cached_redirect(
323323
&format!("/crate/{}/latest", params.name),
324324
CachePolicy::ForeverInCdn,
325-
)
325+
)?
326326
.into_response());
327327
}
328328

@@ -345,7 +345,7 @@ pub(crate) async fn crate_details_handler(
345345
return Ok(super::axum_cached_redirect(
346346
&format!("/crate/{}/{}", &params.name, version),
347347
CachePolicy::ForeverInCdn,
348-
)
348+
)?
349349
.into_response());
350350
}
351351
};

src/web/mod.rs

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -539,14 +539,17 @@ fn redirect(url: Url) -> Response {
539539
resp
540540
}
541541

542-
fn axum_redirect(url: &str) -> impl IntoResponse {
543-
(
542+
fn axum_redirect(url: &str) -> Result<impl IntoResponse, Error> {
543+
if !url.starts_with('/') || url.starts_with("//") {
544+
return Err(anyhow!("invalid redirect URL: {}", url));
545+
}
546+
Ok((
544547
StatusCode::FOUND,
545548
[(
546549
http::header::LOCATION,
547550
http::HeaderValue::try_from(url).expect("invalid url for redirect"),
548551
)],
549-
)
552+
))
550553
}
551554

552555
fn cached_redirect(url: Url, cache_policy: cache::CachePolicy) -> Response {
@@ -555,10 +558,13 @@ fn cached_redirect(url: Url, cache_policy: cache::CachePolicy) -> Response {
555558
resp
556559
}
557560

558-
fn axum_cached_redirect(url: &str, cache_policy: cache::CachePolicy) -> impl IntoResponse {
559-
let mut resp = axum_redirect(url).into_response();
561+
fn axum_cached_redirect(
562+
url: &str,
563+
cache_policy: cache::CachePolicy,
564+
) -> Result<impl IntoResponse, Error> {
565+
let mut resp = axum_redirect(url)?.into_response();
560566
resp.extensions_mut().insert(cache_policy);
561-
resp
567+
Ok(resp)
562568
}
563569

564570
fn redirect_base(req: &Request) -> String {
@@ -707,8 +713,10 @@ impl_axum_webpage! {
707713
mod test {
708714
use super::*;
709715
use crate::{docbuilder::DocCoverage, test::*, web::match_version};
716+
use axum::http::StatusCode;
710717
use kuchiki::traits::TendrilSink;
711718
use serde_json::json;
719+
use test_case::test_case;
712720

713721
fn release(version: &str, env: &TestEnvironment) -> i32 {
714722
env.fake_release()
@@ -1158,4 +1166,42 @@ mod test {
11581166
Ok(())
11591167
});
11601168
}
1169+
1170+
#[test]
1171+
fn test_axum_redirect() {
1172+
let response = axum_redirect("/something").unwrap().into_response();
1173+
assert_eq!(response.status(), StatusCode::FOUND);
1174+
assert_eq!(
1175+
response.headers().get(http::header::LOCATION).unwrap(),
1176+
"/something"
1177+
);
1178+
assert!(response
1179+
.headers()
1180+
.get(http::header::CACHE_CONTROL)
1181+
.is_none());
1182+
assert!(response.extensions().get::<cache::CachePolicy>().is_none());
1183+
}
1184+
1185+
#[test]
1186+
fn test_axum_redirect_cached() {
1187+
let response = axum_cached_redirect("/something", cache::CachePolicy::NoCaching)
1188+
.unwrap()
1189+
.into_response();
1190+
assert_eq!(response.status(), StatusCode::FOUND);
1191+
assert_eq!(
1192+
response.headers().get(http::header::LOCATION).unwrap(),
1193+
"/something"
1194+
);
1195+
assert!(matches!(
1196+
response.extensions().get::<cache::CachePolicy>().unwrap(),
1197+
cache::CachePolicy::NoCaching,
1198+
))
1199+
}
1200+
1201+
#[test_case("without_leading_slash")]
1202+
#[test_case("//with_double_leading_slash")]
1203+
fn test_axum_redirect_failure(path: &str) {
1204+
assert!(axum_redirect(path).is_err());
1205+
assert!(axum_cached_redirect(path, cache::CachePolicy::NoCaching).is_err());
1206+
}
11611207
}

0 commit comments

Comments
 (0)