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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user