diff --git a/src/services/provider/mod.rs b/src/services/provider/mod.rs index c5f4bf244..430208bad 100644 --- a/src/services/provider/mod.rs +++ b/src/services/provider/mod.rs @@ -15,7 +15,7 @@ use crate::constants::{ DEFAULT_HTTP_CLIENT_TCP_KEEPALIVE_SECONDS, NONCE_TOO_HIGH_PATTERNS, }; use crate::models::{EvmNetwork, RpcConfig, SolanaNetwork, StellarNetwork}; -use crate::utils::create_secure_redirect_policy; +use crate::utils::{create_secure_redirect_policy, mask_url}; use serde::Serialize; use thiserror::Error; @@ -229,6 +229,18 @@ impl From for ProviderError { /// # Returns /// /// The appropriate `ProviderError` variant based on the error type +/// Returns the error text with the request URL masked. +/// +/// reqwest appends the full request URL to its messages (`... for url (...)`), so an API +/// key in the path or query of an RPC URL would otherwise reach logs and error reasons. +fn reqwest_error_text(err: &reqwest::Error) -> String { + let text = err.to_string(); + match err.url() { + Some(url) => text.replace(url.as_str(), &mask_url(url.as_str())), + None => text, + } +} + fn categorize_reqwest_error(err: &reqwest::Error) -> ProviderError { if err.is_timeout() { return ProviderError::Timeout; @@ -240,14 +252,14 @@ fn categorize_reqwest_error(err: &reqwest::Error) -> ProviderError { 502 => return ProviderError::BadGateway, _ => { return ProviderError::RequestError { - error: err.to_string(), + error: reqwest_error_text(err), status_code: status.as_u16(), } } } } - ProviderError::Other(err.to_string()) + ProviderError::Other(reqwest_error_text(err)) } impl From for ProviderError { @@ -722,6 +734,56 @@ mod tests { assert!(matches!(provider_error, ProviderError::Other(_))); } + #[actix_rt::test] + async fn test_categorize_reqwest_error_masks_url_in_message() { + // Nothing listens on port 9, so the request fails to connect + let client = reqwest::Client::new(); + let err = client + .get("http://127.0.0.1:9/v2/SECRET_KEY_PATH?apikey=SECRET_KEY_QUERY") + .send() + .await + .unwrap_err(); + assert!(err.to_string().contains("SECRET_KEY_PATH")); + + let provider_error = categorize_reqwest_error(&err); + assert!(matches!(provider_error, ProviderError::Other(_))); + let message = provider_error.to_string(); + assert!(!message.contains("SECRET_KEY"), "{message}"); + assert!(message.contains("http://127.0.0.1:9/***"), "{message}"); + } + + #[actix_rt::test] + async fn test_categorize_reqwest_error_status_masks_url_in_message() { + let mut mock_server = mockito::Server::new_async().await; + + let _mock = mock_server + .mock("GET", mockito::Matcher::Any) + .with_status(500) + .create_async() + .await; + + let client = reqwest::Client::new(); + let err = client + .get(format!("{}/v3/SECRET_KEY", mock_server.url())) + .send() + .await + .expect("Failed to get response") + .error_for_status() + .expect_err("Expected error for status 500"); + assert!(err.to_string().contains("SECRET_KEY")); + + let provider_error = categorize_reqwest_error(&err); + assert!(matches!( + provider_error, + ProviderError::RequestError { + status_code: 500, + .. + } + )); + let message = provider_error.to_string(); + assert!(!message.contains("SECRET_KEY"), "{message}"); + } + #[actix_rt::test] async fn test_shared_rpc_client_zstd_response_decompression() { let mut mock_server = mockito::Server::new_async().await; diff --git a/src/utils/url.rs b/src/utils/url.rs index 6e1a5341b..9547adb79 100644 --- a/src/utils/url.rs +++ b/src/utils/url.rs @@ -12,6 +12,7 @@ /// - `https://eth-mainnet.g.alchemy.com/v2/abc123` → `https://eth-mainnet.g.alchemy.com/***` /// - `https://mainnet.infura.io/v3/PROJECT_ID` → `https://mainnet.infura.io/***` /// - `http://localhost:8545` → `http://localhost:8545` (no path to mask) +/// - `https://user:pass@rpc.example.com/v1` → `https://***@rpc.example.com/***` (userinfo is always hidden) /// - `invalid-url` → `***` (fallback for unparsable URLs) pub fn mask_url(url: &str) -> String { // Find the scheme separator "://" @@ -22,6 +23,20 @@ pub fn mask_url(url: &str) -> String { // Find where the host ends (first "/" after "://") let host_start = scheme_end + 3; // Skip "://" + + // Userinfo ("user:password@") carries credentials, so it is never shown + let authority_end = url[host_start..] + .find(['/', '?', '#']) + .map_or(url.len(), |i| host_start + i); + let without_userinfo; + let url = match url[host_start..authority_end].rfind('@') { + Some(at) => { + without_userinfo = format!("{}***@{}", &url[..host_start], &url[host_start + at + 1..]); + without_userinfo.as_str() + } + None => url, + }; + let rest = &url[host_start..]; // Find the first "/" which marks the start of the path @@ -129,4 +144,34 @@ mod tests { let masked = mask_url(url); assert_eq!(masked, "https://rpc.ankr.com/***"); } + + #[test] + fn test_mask_url_hides_userinfo_with_path() { + let url = "https://user:SECRET@rpc.example.com/rpc"; + let masked = mask_url(url); + assert_eq!(masked, "https://***@rpc.example.com/***"); + } + + #[test] + fn test_mask_url_hides_userinfo_without_path() { + assert_eq!( + mask_url("https://user:SECRET@rpc.example.com"), + "https://***@rpc.example.com" + ); + assert_eq!( + mask_url("https://user:SECRET@rpc.example.com:8545/"), + "https://***@rpc.example.com:8545/" + ); + assert_eq!( + mask_url("https://user:SECRET@rpc.example.com?key=abc"), + "https://***@rpc.example.com?***" + ); + } + + #[test] + fn test_mask_url_at_sign_in_path_is_not_userinfo() { + let url = "https://rpc.example.com/v1/user@example.org"; + let masked = mask_url(url); + assert_eq!(masked, "https://rpc.example.com/***"); + } }