diff --git a/Cargo.toml b/Cargo.toml index 5860b19d45..5ae4251d15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ hyper = "1.5" hyper-rustls = { version = "0.27", default-features = false } hyper-util = "0.1" parking_lot = "0.12" +percent-encoding = "2.3.2" pin-project = "1.1.3" proc-macro-crate = "3" proc-macro2 = "1" diff --git a/client/http-client/Cargo.toml b/client/http-client/Cargo.toml index f1ec306a0d..48a375d488 100644 --- a/client/http-client/Cargo.toml +++ b/client/http-client/Cargo.toml @@ -24,6 +24,7 @@ hyper-util = { workspace = true, features = ["client", "client-legacy", "tokio", http-body = { workspace = true } jsonrpsee-types = { workspace = true } jsonrpsee-core = { workspace = true, features = ["client", "http-helpers"] } +percent-encoding = { workspace = true } rustls = { workspace = true, optional = true, features = ["logging", "std", "tls12", "ring"] } rustls-platform-verifier = { workspace = true, optional = true } serde = { workspace = true, features = ["alloc"] } diff --git a/client/http-client/src/transport.rs b/client/http-client/src/transport.rs index 8226758f4b..adda7c7404 100644 --- a/client/http-client/src/transport.rs +++ b/client/http-client/src/transport.rs @@ -17,6 +17,7 @@ use jsonrpsee_core::{ TEN_MB_SIZE_BYTES, http_helpers::{self, HttpError}, }; +use percent_encoding::percent_decode_str; use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; @@ -308,7 +309,13 @@ impl HttpTransportClientBuilder { if let Some(pwd) = url.password() { if !cached_headers.contains_key(hyper::header::AUTHORIZATION) { - let digest = base64::engine::general_purpose::STANDARD.encode(format!("{}:{pwd}", url.username())); + let usr = percent_decode_str(url.username()) + .decode_utf8() + .map_err(|_| Error::Url("username is not valid utf8".into()))?; + let pwd = percent_decode_str(pwd) + .decode_utf8() + .map_err(|_| Error::Url("password is not valid utf8".into()))?; + let digest = base64::engine::general_purpose::STANDARD.encode(format!("{usr}:{pwd}")); cached_headers.insert( hyper::header::AUTHORIZATION, HeaderValue::from_str(&format!("Basic {digest}")) @@ -504,4 +511,23 @@ mod tests { let response = client.send(body).await.unwrap_err(); assert!(matches!(response, Error::RequestTooLarge)); } + + #[test] + fn http_with_username_and_password() { + let client = HttpTransportClientBuilder::new().build("http://user:pwd@localhost:9999").unwrap(); + assert_eq!(client.headers["authorization"], "Basic dXNlcjpwd2Q="); + } + + #[test] + fn http_with_special_username_and_password() { + let client = HttpTransportClientBuilder::new().build("http://=:=@localhost:9999").unwrap(); + assert_eq!(client.headers["authorization"], "Basic PTo9"); + } + + #[test] + fn http_with_percent_username_and_password() { + // username "a", password "b" + let client = HttpTransportClientBuilder::new().build("http://%61:%62@localhost:9999").unwrap(); + assert_eq!(client.headers["authorization"], "Basic YTpi"); + } } diff --git a/client/transport/Cargo.toml b/client/transport/Cargo.toml index c641442d39..4cb1602c57 100644 --- a/client/transport/Cargo.toml +++ b/client/transport/Cargo.toml @@ -28,6 +28,7 @@ tokio-util = { workspace = true, features = ["compat"], optional = true } tokio = { workspace = true, features = ["net", "time", "macros"], optional = true } pin-project = { workspace = true, optional = true } url = { workspace = true, optional = true } +percent-encoding = { workspace = true, optional = true } base64 = { workspace = true, optional = true } # tls @@ -59,6 +60,7 @@ ws = [ "thiserror", "tracing", "url", + "percent-encoding", ] web = [ "gloo-net", diff --git a/client/transport/src/ws/mod.rs b/client/transport/src/ws/mod.rs index 8e00741c48..8ff1108010 100644 --- a/client/transport/src/ws/mod.rs +++ b/client/transport/src/ws/mod.rs @@ -35,6 +35,7 @@ use futures_util::io::{BufReader, BufWriter}; use jsonrpsee_core::Cow; use jsonrpsee_core::TEN_MB_SIZE_BYTES; use jsonrpsee_core::client::{ReceivedMessage, TransportReceiverT, TransportSenderT}; +use percent_encoding::percent_decode_str; use soketto::connection::CloseReason; use soketto::connection::Error::Utf8; use soketto::data::ByteSlice125; @@ -633,7 +634,13 @@ impl TryFrom for Target { } let basic_auth = if let Some(pwd) = url.password() { - let digest = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", url.username(), pwd)); + let usr = percent_decode_str(url.username()) + .decode_utf8() + .map_err(|_| WsHandshakeError::Url("username is not valid utf8".into()))?; + let pwd = percent_decode_str(pwd) + .decode_utf8() + .map_err(|_| WsHandshakeError::Url("password is not valid utf8".into()))?; + let digest = base64::engine::general_purpose::STANDARD.encode(format!("{usr}:{pwd}")); let val = HeaderValue::from_str(&format!("Basic {digest}")) .map_err(|_| WsHandshakeError::Url("Header value `authorization basic user:pwd` invalid".into()))?; @@ -771,4 +778,27 @@ mod tests { assert_ws_target(target, "127.0.0.1", "127.0.0.1", Mode::Plain, "/", Some(basic_auth)); } + + #[test] + fn ws_with_special_username_and_password() { + use base64::Engine; + + let target = parse_target("ws://=:=@127.0.0.1").unwrap(); + let digest = base64::engine::general_purpose::STANDARD.encode("=:="); + let basic_auth = HeaderValue::from_str(&format!("Basic {digest}")).unwrap(); + + assert_ws_target(target, "127.0.0.1", "127.0.0.1", Mode::Plain, "/", Some(basic_auth)); + } + + #[test] + fn ws_with_percent_username_and_password() { + use base64::Engine; + + // username "a", password "b" + let target = parse_target("ws://%61:%62@127.0.0.1").unwrap(); + let digest = base64::engine::general_purpose::STANDARD.encode("a:b"); + let basic_auth = HeaderValue::from_str(&format!("Basic {digest}")).unwrap(); + + assert_ws_target(target, "127.0.0.1", "127.0.0.1", Mode::Plain, "/", Some(basic_auth)); + } }