clawhdf5-remote: redirects are followed safely
ureq's defaults followed up to 10 redirects, including from https to plain http, and forwarded the custom HttpOptions::headers (X-Api-Key, Cookie, ...) to whatever host a redirect named — only Authorization was stripped. HttpStorage now follows redirects itself (ureq's max_redirects is 0): - at most HttpOptions::max_redirects per request (default 5; 0 refuses any redirect), then RemoteError::Redirect; - never from https to another scheme, nor to a non-http(s) URL; - once a redirect leaves the URL's origin (scheme, host, port), none of the custom headers is sent any more (Authorization included); - each hop counts as a request; errors show the target redacted. Tests: a redirect to another local port reads the right data and the target never sees X-Api-Key or Authorization (it did before); a same-origin redirect keeps them; a loop stops after 6 requests; 0 refuses; unit tests for target resolution, the https downgrade and origins. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
@@ -722,3 +722,58 @@ fn credentials_never_appear_in_errors_or_debug() {
|
||||
"https://host:8/d/f.h5?X-Amz-Signature=REDACTED&a=REDACTED"
|
||||
);
|
||||
}
|
||||
|
||||
/// Redirects are followed within limits: custom credential headers reach
|
||||
/// only the URL's own origin (not another port a redirect leads to), a
|
||||
/// same-origin redirect keeps them, loops end at `max_redirects`, and
|
||||
/// `max_redirects = 0` refuses any redirect. (The https→http downgrade
|
||||
/// refusal is a unit test of `redirect_target`: no TLS server here.)
|
||||
#[test]
|
||||
fn redirects_are_followed_safely() {
|
||||
let bytes = multi_block_file();
|
||||
let local = File::from_bytes(bytes.clone()).unwrap();
|
||||
let want = local.dataset("big").unwrap().read_f64().unwrap();
|
||||
let target = Server::start(vec![("/m.h5".into(), bytes.clone())]);
|
||||
let front = Server::start(vec![("/m.h5".into(), bytes.clone())]);
|
||||
front.redirect("/r.h5", &target.url("/m.h5"));
|
||||
front.redirect("/s.h5", "/m.h5");
|
||||
front.redirect("/loop.h5", "/loop.h5");
|
||||
let mut opts = quick();
|
||||
opts.http.headers = vec![
|
||||
("X-Api-Key".into(), "sekrit".into()),
|
||||
("Authorization".into(), "Bearer tok".into()),
|
||||
];
|
||||
|
||||
// Cross-origin (another port): followed, the headers stay behind.
|
||||
let f = open_url_with(&front.url("/r.h5"), &opts).unwrap();
|
||||
assert_eq!(f.dataset("big").unwrap().read_f64().unwrap(), want);
|
||||
assert!(front.saw_header("x-api-key"), "sent to the URL's origin");
|
||||
assert!(target.requests() > 1);
|
||||
assert!(
|
||||
!target.saw_header("x-api-key") && !target.saw_header("authorization"),
|
||||
"credential headers forwarded across origins: {:?}",
|
||||
target.shared.seen.lock().unwrap()
|
||||
);
|
||||
|
||||
// Same origin: followed with the headers.
|
||||
front.reset();
|
||||
let f = open_url_with(&front.url("/s.h5"), &opts).unwrap();
|
||||
assert_eq!(f.dataset("big").unwrap().read_f64().unwrap(), want);
|
||||
let seen = front.shared.seen.lock().unwrap().clone();
|
||||
assert!(
|
||||
seen.iter()
|
||||
.filter(|(p, _)| p == "/m.h5")
|
||||
.all(|(_, h)| h.get("x-api-key").map(String::as_str) == Some("sekrit"))
|
||||
);
|
||||
|
||||
// A loop ends after max_redirects (5 by default): 6 requests.
|
||||
front.reset();
|
||||
let e = open_url_with(&front.url("/loop.h5"), &opts).unwrap_err();
|
||||
assert!(matches!(e, Error::Remote(RemoteError::Redirect(_))), "{e}");
|
||||
assert_eq!(front.requests(), 6);
|
||||
|
||||
// No redirects allowed.
|
||||
opts.http.max_redirects = 0;
|
||||
let e = open_url_with(&front.url("/s.h5"), &opts).unwrap_err();
|
||||
assert!(matches!(e, Error::Remote(RemoteError::Redirect(_))), "{e}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user