fix(config): strip userinfo from a scheme-less base URL before fingerprinting

`base_url_fingerprint`'s doc comment states that "userinfo, query
credentials, and fragments never enter the digest function at all". The
scheme-bearing branch of `secret_free_fingerprint_input` honours that: it
splits the authority and drops everything before `@`. The fall-through
branch — taken by any `base_url` typed without a scheme, which is a shape
users do write — only split off `?`/`#`:

    secret_free_fingerprint_input("u:p4ssw0rd@host/v1") == "u:p4ssw0rd@host/v1"

so the password was hashed verbatim into a value that is stored in the
catalog cache, embedded in `LivePricingDefect::EndpointMismatch`
receipts, and logged as a non-secret. The doc comment directly above it
promised the opposite — the same shape as the "never contains a secret"
comment found over a URL interpolation earlier today.

The fall-through now strips userinfo exactly as the scheme branch does.
That also makes the branch's `unwrap_or(REDACTED)` reachable in spirit:
`split` always yields at least one (possibly empty) piece, so it never
fired, and an empty base URL was fingerprinting SHA-256 of the empty
string (`e3b0c442…`) rather than the redacted sentinel. An empty
authority now returns the sentinel, matching the scheme branch.

This changes the fingerprint of scheme-less and empty base URLs. They are
cache keys only, so the cost is one refetch per affected scope.

Evidence: `fingerprint_strips_userinfo_from_a_scheme_less_base_url` and
`fingerprint_of_an_empty_base_url_is_the_redacted_constant` both fail on
the pre-change fall-through and pass after.
`cargo test -p codewhale-config --lib` 578 pass.

Implemented with agent assistance.

Signed-off-by: Hunter Bown <[email protected]>
This commit is contained in:
Hunter Bown committed 2026-08-20 16:14:37 -07:00
1 parent 8e105845de
commit 3881254912
2 files changed
+39 -1

No files matched your search

+13 -1
View File
@@ -711,7 +711,19 @@ fn secret_free_fingerprint_input(base_url: &str) -> String {
.unwrap_or_default();
return normalize_base_url(&format!("{scheme}://{authority}{path}"));
}
normalize_base_url(trimmed.split(['?', '#']).next().unwrap_or(REDACTED))
// Scheme-less input still has an authority, and it can still carry
// `user:pass@` userinfo. Strip it exactly as the scheme branch does, so the
// digest input never contains a credential.
let without_query = trimmed.split(['?', '#']).next().unwrap_or_default();
let authority_end = without_query.find('/').unwrap_or(without_query.len());
let authority = &without_query[..authority_end];
let authority = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
if authority.is_empty() {
return REDACTED.to_string();
}
normalize_base_url(&format!("{authority}{}", &without_query[authority_end..]))
}
fn normalize_base_url(base_url: &str) -> String {
+26
View File
@@ -306,6 +306,32 @@ fn fingerprint_never_hashes_secret_bearing_url_text() {
}
}
#[test]
fn fingerprint_strips_userinfo_from_a_scheme_less_base_url() {
// A base_url typed without a scheme took the fall-through branch, which
// only split off `?`/`#` — so `user:pass@host` went into SHA-256 verbatim,
// against the documented "userinfo never enters the digest function".
let expected = base_url_fingerprint("api.example.com/v1");
for url in [
"user:[email protected]/v1",
"user:[email protected]/v1",
"[email protected]/v1",
] {
assert_eq!(base_url_fingerprint(url), expected, "{url}");
}
}
#[test]
fn fingerprint_of_an_empty_base_url_is_the_redacted_constant() {
// The fall-through's `unwrap_or(REDACTED)` never fired — `split` always
// yields at least one (possibly empty) piece — so an empty base URL
// fingerprinted the empty string instead of the redacted sentinel.
let redacted = base_url_fingerprint("ftp://api.example.com");
for url in ["", " ", "?api_key=secret"] {
assert_eq!(base_url_fingerprint(url), redacted, "{url:?}");
}
}
#[test]
fn ttl_marks_entries_stale_and_excludes_them_from_fresh() {
let fp = base_url_fingerprint("https://api.example.com");