security(mcp): redact HTTP(S)_PROXY userinfo before logging malformed-URL warning

PR #1408's MCP proxy support (commit 865db6248) added a
`tracing::warn!` for malformed `HTTPS_PROXY` values that included
the raw URL via `proxy = %proxy_url`. With v0.8.29's new
file-backed tracing subscriber (writing to
`~/.deepseek/logs/tui-YYYY-MM-DD.log`), that means a corporate
proxy URL of the shape `http://user:[email protected]/` would
leak the password to disk whenever reqwest rejected the URL.

Fix: redact the `user:pass@` userinfo segment before logging via
a new `redact_proxy_userinfo()` helper. `http://alice:hunter2@proxy/`
becomes `http://***@proxy/`. URLs without userinfo are returned
unchanged; the `@` is only treated as a userinfo separator when it
appears before any `/`, `?`, or `#` (so path-embedded `@` doesn't
trigger redaction). Garbage input (no `://`) passes through — the
warning log site is already in the malformed-URL failure path.

Pinned by `redact_proxy_userinfo_strips_password` covering five
cases: full creds, user-only, no-userinfo, path-only-`@`, and
garbage. The non-malformed path (where reqwest accepts the URL)
never logs the URL at all, so this is the only leak vector.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Hunter BownandClaude Opus 4.7 committed 2026-05-10 21:26:45 -05:00
1 parent 47f6d69e58
commit 09dcbede08
1 file changed
+67 -1
+67 -1
View File
@@ -58,6 +58,39 @@ fn mask_url_secrets(url: &str) -> String {
url.to_string()
}
/// Redact the `user:pass@` userinfo segment from a proxy URL so it can be
/// safely included in `tracing::warn!` output without leaking the
/// password into the on-disk log. URLs without userinfo are returned
/// unchanged. Garbage input (no `://` scheme separator) is also returned
/// unchanged — the malformed-URL warning path is the only caller, so an
/// unparseable input is already the failure case.
fn redact_proxy_userinfo(proxy_url: &str) -> String {
let Some(scheme_end) = proxy_url.find("://") else {
return proxy_url.to_string();
};
let after_scheme = scheme_end + 3;
// The userinfo segment ends at the next `@`, but only if that `@`
// comes before the next `/`, `?`, or `#` (otherwise the `@` is in a
// path / query and the URL has no userinfo at all).
let rest = &proxy_url[after_scheme..];
let at_idx = rest.find('@');
let path_idx = rest.find(['/', '?', '#']);
let userinfo_end = match (at_idx, path_idx) {
(Some(a), Some(p)) if a < p => Some(a),
(Some(a), None) => Some(a),
_ => None,
};
if let Some(end) = userinfo_end {
let mut out = String::with_capacity(proxy_url.len());
out.push_str(&proxy_url[..after_scheme]);
out.push_str("***@");
out.push_str(&rest[end + 1..]);
out
} else {
proxy_url.to_string()
}
}
/// Mask any obvious token-like substrings in a body excerpt before surfacing
/// it. Conservative: replaces `Bearer <token>` and `api_key=...` shapes.
fn redact_body_preview(body: &str) -> String {
@@ -957,10 +990,15 @@ impl McpConnection {
client_builder = client_builder.proxy(proxy);
}
Err(err) => {
// Redact userinfo (`user:pass@host` form) before
// logging so an HTTPS_PROXY that embeds credentials
// (common in corporate setups) doesn't leak the
// password to the on-disk `~/.deepseek/logs/`.
let proxy_redacted = redact_proxy_userinfo(&proxy_url);
tracing::warn!(
target: "mcp",
?err,
proxy = %proxy_url,
proxy = %proxy_redacted,
"ignoring malformed HTTP(S)_PROXY env var; MCP connection will bypass proxy"
);
}
@@ -3133,6 +3171,34 @@ mod tests {
assert!(!redacted.contains("abc.def.ghi"), "leaked: {redacted}");
}
#[test]
fn redact_proxy_userinfo_strips_password() {
// Corporate-style proxy URL with embedded creds — the
// password must never reach the on-disk log file.
let redacted = redact_proxy_userinfo("http://alice:[email protected]/");
assert_eq!(redacted, "http://***@proxy.example/");
assert!(!redacted.contains("hunter2"));
assert!(!redacted.contains("alice"));
// User only (no password) — still redacted.
let redacted = redact_proxy_userinfo("https://[email protected]:8080");
assert_eq!(redacted, "https://***@proxy.example:8080");
// No userinfo segment — pass through.
let redacted = redact_proxy_userinfo("http://proxy.example:3128/");
assert_eq!(redacted, "http://proxy.example:3128/");
// `@` appears only in the path, not as userinfo separator —
// must not be mistaken for credentials.
let redacted = redact_proxy_userinfo("http://proxy.example/path@thing");
assert_eq!(redacted, "http://proxy.example/path@thing");
// Garbage input (no `://`) returned unchanged — the
// surrounding warning log is the only caller and is already
// handling the malformed-URL case.
assert_eq!(redact_proxy_userinfo("not-a-url"), "not-a-url");
}
#[test]
fn redact_body_preview_masks_api_key_param() {
let redacted = redact_body_preview("error message api_key=sk-12345&other=val");