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
+36 -1
View File
@@ -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));