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:
@@ -57,6 +57,10 @@ pub struct Shared {
|
||||
pub force_status: AtomicU32,
|
||||
/// Answer ranges with a `Content-Range` one byte off.
|
||||
pub wrong_range: AtomicBool,
|
||||
/// Path → `Location`: answered `302 Found` (counted as a request).
|
||||
pub redirects: RwLock<HashMap<String, String>>,
|
||||
/// (path, headers) of every counted request, header names lowercase.
|
||||
pub seen: Mutex<Vec<(String, HashMap<String, String>)>>,
|
||||
/// Requests for a served path (every status); requests for other
|
||||
/// paths are not counted.
|
||||
pub requests: AtomicU64,
|
||||
@@ -119,11 +123,31 @@ impl Server {
|
||||
self.shared.bytes.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Zero the counters and the log.
|
||||
/// Zero the counters and the logs.
|
||||
pub fn reset(&self) {
|
||||
self.shared.requests.store(0, Ordering::SeqCst);
|
||||
self.shared.bytes.store(0, Ordering::SeqCst);
|
||||
self.shared.log.lock().unwrap().clear();
|
||||
self.shared.seen.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Answer `path` with a redirect to `location`.
|
||||
pub fn redirect(&self, path: &str, location: &str) {
|
||||
self.shared
|
||||
.redirects
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(path.to_string(), location.to_string());
|
||||
}
|
||||
|
||||
/// Whether any request the server counted carried header `name`.
|
||||
pub fn saw_header(&self, name: &str) -> bool {
|
||||
self.shared
|
||||
.seen
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|(_, h)| h.contains_key(name))
|
||||
}
|
||||
|
||||
/// The ranges asked for so far.
|
||||
@@ -242,6 +266,16 @@ fn serve(conn: TcpStream, s: &Shared) -> std::io::Result<()> {
|
||||
let close = headers
|
||||
.get("connection")
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("close"));
|
||||
let redirect = s.redirects.read().unwrap().get(&path).cloned();
|
||||
if let Some(location) = redirect {
|
||||
s.requests.fetch_add(1, Ordering::SeqCst);
|
||||
s.seen.lock().unwrap().push((path.clone(), headers.clone()));
|
||||
write!(
|
||||
out,
|
||||
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\n\r\n"
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
let res = {
|
||||
let files = s.files.read().unwrap();
|
||||
files
|
||||
@@ -257,6 +291,7 @@ fn serve(conn: TcpStream, s: &Shared) -> std::io::Result<()> {
|
||||
continue;
|
||||
};
|
||||
s.requests.fetch_add(1, Ordering::SeqCst);
|
||||
s.seen.lock().unwrap().push((path.clone(), headers.clone()));
|
||||
let delay = s.delay_ms.load(Ordering::SeqCst);
|
||||
if delay > 0 {
|
||||
std::thread::sleep(Duration::from_millis(delay));
|
||||
|
||||
@@ -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