Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions src/services/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -229,6 +229,18 @@ impl From<ParseIntError> 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;
Expand All @@ -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<reqwest::Error> for ProviderError {
Expand Down Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions src/utils/url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 "://"
Expand All @@ -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
Expand Down Expand Up @@ -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/***");
}
}
Loading