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:
osobh
2026-09-26 18:31:39 -05:00
co-authored by Claude Opus 5.5
parent c04e34620e
commit 680c90b3a8
4 changed files with 260 additions and 15 deletions
+55
View File
@@ -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}");
}