diff --git a/Cargo.lock b/Cargo.lock index 238710b4b0f..382d4755dcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2446,6 +2446,7 @@ dependencies = [ "noq", "noq-proto", "num_enum", + "percent-encoding", "pin-project", "postcard", "proptest", diff --git a/iroh-relay/Cargo.toml b/iroh-relay/Cargo.toml index 559c2ade51a..81bbcf14876 100644 --- a/iroh-relay/Cargo.toml +++ b/iroh-relay/Cargo.toml @@ -40,6 +40,7 @@ lru = "0.18.3" n0-error = "1.0.0" n0-future = "0.3" num_enum = "0.7" +percent-encoding = "2.3" pin-project = "1" postcard = { version = "1", default-features = false, features = [ "alloc", diff --git a/iroh-relay/src/client/tls.rs b/iroh-relay/src/client/tls.rs index 2787d9ac440..5403519e43f 100644 --- a/iroh-relay/src/client/tls.rs +++ b/iroh-relay/src/client/tls.rs @@ -9,7 +9,7 @@ use std::{collections::VecDeque, net::IpAddr}; use bytes::Bytes; -use data_encoding::BASE64URL; +use data_encoding::BASE64; use http_body_util::Empty; use hyper::{Request, upgrade::Parts}; use hyper_util::rt::TokioIo; @@ -18,6 +18,7 @@ use n0_future::{ FuturesUnordered, MaybeFuture, StreamExt, task, time::{self}, }; +use percent_encoding::percent_decode_str; use rustls::client::Resumption; use tokio::net::TcpStream; use tracing::{Instrument, error, info_span}; @@ -180,12 +181,11 @@ impl MaybeTlsStreamBuilder { "setting proxy-authorization: username={}", proxy_url.username() ); - let to_encode = format!( - "{}:{}", - proxy_url.username(), - proxy_url.password().unwrap_or_default() - ); - let encoded = BASE64URL.encode(to_encode.as_bytes()); + // `Url` keeps the username and password percent-encoded. + let mut to_encode: Vec = percent_decode_str(proxy_url.username()).collect(); + to_encode.push(b':'); + to_encode.extend(percent_decode_str(proxy_url.password().unwrap_or_default())); + let encoded = BASE64.encode(&to_encode); req_builder = req_builder.header("Proxy-Authorization", format!("Basic {encoded}")); } let req = req_builder @@ -447,4 +447,78 @@ mod tests { .expect_err("no addresses to dial"); assert!(matches!(err, DialError::Dns { .. })); } + + /// Dials through a fake proxy with the given userinfo and returns the + /// `Proxy-Authorization` header the proxy received. + /// + /// The proxy reads the `CONNECT` request and hangs up, so the dial fails. + #[cfg(with_crypto_provider)] + async fn proxy_authorization_for(userinfo: &str) -> String { + use std::time::Duration; + + use tokio::io::AsyncReadExt; + + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let proxy = task::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(stream.read_u8().await.unwrap()); + } + String::from_utf8(head).unwrap() + }); + + let proxy_url = format!("http://{userinfo}@proxy.test:{port}") + .parse() + .expect("valid url"); + let resolver = static_resolver(vec![Ipv4Addr::LOCALHOST], vec![]); + let res = MaybeTlsStreamBuilder::new( + relay_url(443), + resolver, + crate::tls::make_dangerous_client_config(), + ) + .proxy_url(Some(proxy_url)) + .connect() + .await; + assert!(res.is_err(), "the proxy never opens the tunnel"); + + let head = time::timeout(Duration::from_secs(5), proxy) + .await + .expect("the proxy received no request") + .unwrap(); + head.lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("proxy-authorization") + .then(|| value.trim().to_string()) + }) + .expect("no Proxy-Authorization header") + } + + /// Tests that the proxy credentials use the standard base64 alphabet (RFC 7617). + #[cfg(with_crypto_provider)] + #[tokio::test] + async fn proxy_authorization_uses_standard_base64() { + // The URL-safe alphabet gives "dXNlcjpzM2NyZXR-fg==". + assert_eq!( + proxy_authorization_for("user:s3cret~~").await, + "Basic dXNlcjpzM2NyZXR+fg==" + ); + } + + /// Tests that the proxy credentials are percent-decoded before they are encoded. + #[cfg(with_crypto_provider)] + #[tokio::test] + async fn proxy_authorization_decodes_credentials() { + // `Url` keeps these passwords as "s3cret%3E%3E" and "p%40ss". + assert_eq!( + proxy_authorization_for("user:s3cret>>").await, + "Basic dXNlcjpzM2NyZXQ+Pg==" + ); + assert_eq!( + proxy_authorization_for("user:p%40ss").await, + "Basic dXNlcjpwQHNz" + ); + } }