clawhdf5-remote, h5rs: URLs' credentials are never shown

Every RemoteError message and HttpStorage's Debug output held the URL as
given, with any user:password@ and the query string — for a presigned
S3/GCS/Azure URL, its signature or token. An application logging the
error leaked the credential.

- New clawhdf5_remote::redact_url: no userinfo, no fragment, query values
  replaced by REDACTED (plain key names kept).
- HttpStorage formats every message with the redacted URL, and scrubs the
  URL's secret parts from errors of the HTTP client (whose texts can echo
  the URI); Debug shows the redacted URL. storage_for_url's and the object
  store URL errors are redacted too. HttpStorage::url() still returns the
  URL as given, documented as not for logging.
- h5rs prints FILE arguments that are URLs redacted: in errors and in
  dump/stat/check/diff output.
- The test server can force a status and send a wrong Content-Range.

Tests: 404, 403 (at open and on a read), wrong Content-Range (at open and
on a read), no range support, encoded body, ETag change, timeout,
connection closed and bad scheme errors, Display and Debug, contain none
of the secrets; h5rs likewise for every subcommand.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 18:30:02 -05:00
co-authored by Claude Opus 5.5
parent 8df5b209a7
commit c04e34620e
12 changed files with 421 additions and 49 deletions
+140
View File
@@ -2,8 +2,126 @@
use clawhdf5_format::error::FormatError;
/// `url` as it may be shown in an error, a `Debug` output or a log: no
/// userinfo (`user:password@`), no fragment, and the query string's values
/// replaced by `REDACTED` (a presigned S3/GCS/Azure URL carries its
/// signature or token there). Keys are kept when they look like plain
/// names, so a message still says which kind of URL it was.
///
/// ```
/// assert_eq!(
/// clawhdf5_remote::redact_url("https://me:pw@host/f.h5?X-Amz-Signature=abc&a=1#x"),
/// "https://host/f.h5?X-Amz-Signature=REDACTED&a=REDACTED"
/// );
/// ```
pub fn redact_url(url: &str) -> String {
let (scheme, rest) = match url.split_once("://") {
Some((s, r)) => (Some(s), r),
None => (None, url),
};
let rest = rest.split('#').next().unwrap_or("");
let (before_query, query) = match rest.split_once('?') {
Some((a, q)) => (a, Some(q)),
None => (rest, None),
};
let mut out = String::with_capacity(url.len());
if let Some(s) = scheme {
out.push_str(s);
out.push_str("://");
let auth_end = before_query.find('/').unwrap_or(before_query.len());
let (authority, path) = before_query.split_at(auth_end);
out.push_str(
authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host),
);
out.push_str(path);
} else {
out.push_str(before_query);
}
if let Some(q) = query {
out.push('?');
let plain = |k: &str| {
!k.is_empty()
&& k.len() <= 64
&& k.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
};
let parts: Vec<String> = q
.split('&')
.map(|kv| {
let k = kv.split('=').next().unwrap_or("");
if plain(k) {
format!("{k}=REDACTED")
} else {
"REDACTED".to_string()
}
})
.collect();
out.push_str(&parts.join("&"));
}
out
}
/// Replaces the secret parts of one URL (its userinfo and query string,
/// and the URL itself) wherever they appear in a message — such as the
/// text of an error from the HTTP client.
#[derive(Debug, Clone)]
pub(crate) struct Redactor {
shown: String,
secrets: Vec<(String, String)>,
}
impl Redactor {
pub(crate) fn new(url: &str) -> Redactor {
let shown = redact_url(url);
let mut secrets = vec![(url.to_string(), shown.clone())];
let rest = url.split_once("://").map_or(url, |(_, r)| r);
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
if let Some((userinfo, _)) = authority.rsplit_once('@')
&& !userinfo.is_empty()
{
secrets.push((format!("{userinfo}@"), String::new()));
secrets.push((userinfo.to_string(), "REDACTED".into()));
}
if let Some((_, q)) = rest.split('#').next().unwrap_or("").split_once('?')
&& !q.is_empty()
{
let shown_q = shown.split_once('?').map_or("", |(_, q)| q).to_string();
secrets.push((q.to_string(), shown_q));
for kv in q.split('&') {
if let Some((_, v)) = kv.split_once('=')
&& v.len() >= 4
{
secrets.push((v.to_string(), "REDACTED".into()));
}
}
}
Redactor { shown, secrets }
}
/// The URL, redacted.
pub(crate) fn shown(&self) -> &str {
&self.shown
}
/// `msg` with every secret part of the URL replaced.
pub(crate) fn scrub(&self, msg: &str) -> String {
let mut m = msg.to_string();
for (secret, with) in &self.secrets {
if m.contains(secret.as_str()) {
m = m.replace(secret.as_str(), with);
}
}
m
}
}
/// Why a remote file could not be opened or read.
///
/// No message carries a URL's credentials: URLs appear as
/// [`redact_url`] shows them.
///
/// Inside a [`clawhdf5::File`] read these arrive as
/// `clawhdf5::Error::Format(FormatError::Storage(message))`, the message
/// being this error's `Display`.
@@ -54,6 +172,28 @@ pub enum RemoteError {
}
impl RemoteError {
/// The error with every secret part of `r`'s URL scrubbed from its text.
#[cfg_attr(not(any(feature = "http", feature = "object-store")), allow(dead_code))]
pub(crate) fn scrubbed(self, r: &Redactor) -> RemoteError {
let f = |s: String| r.scrub(&s);
match self {
RemoteError::InvalidUrl(s) => RemoteError::InvalidUrl(f(s)),
RemoteError::UnsupportedScheme(s) => RemoteError::UnsupportedScheme(f(s)),
RemoteError::RangeNotSupported(s) => RemoteError::RangeNotSupported(f(s)),
RemoteError::FileChanged(s) => RemoteError::FileChanged(f(s)),
RemoteError::Status { code, what } => RemoteError::Status {
code,
what: f(what),
},
RemoteError::BadResponse(s) => RemoteError::BadResponse(f(s)),
RemoteError::Transport(s) => RemoteError::Transport(f(s)),
RemoteError::ObjectStore(s) => RemoteError::ObjectStore(f(s)),
RemoteError::Usage(s) => RemoteError::Usage(f(s)),
e @ RemoteError::TooLarge { .. } => e,
RemoteError::Backend(s) => RemoteError::Backend(f(s)),
}
}
/// Whether retrying the same request may succeed.
#[cfg_attr(not(feature = "http"), allow(dead_code))]
pub(crate) fn is_transient(&self) -> bool {