diff --git a/Cargo.lock b/Cargo.lock index ac9b127ca587fffafbb19e515aaa7b62bd369612..4a0d3189443a9319da5ed5ad7e712a1b78467ed7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2814,6 +2814,7 @@ dependencies = [ "anyhow", "async-recursion 0.3.2", "async-tungstenite", + "base64 0.22.1", "chrono", "clock", "cocoa 0.26.0", @@ -2825,6 +2826,7 @@ dependencies = [ "gpui_tokio", "http_client", "http_client_tls", + "httparse", "log", "parking_lot", "paths", @@ -2832,6 +2834,7 @@ dependencies = [ "rand 0.8.5", "release_channel", "rpc", + "rustls-pki-types", "schemars", "serde", "serde_json", @@ -2845,6 +2848,8 @@ dependencies = [ "time", "tiny_http", "tokio", + "tokio-native-tls", + "tokio-rustls 0.26.2", "tokio-socks", "url", "util", @@ -13578,11 +13583,12 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ "web-time", + "zeroize", ] [[package]] diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 1ebea995df33407b3001b61b1a26967c11c43ed5..dcbcecb2955d604894ba5acb2a082c33a6c51fb0 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -19,6 +19,7 @@ test-support = ["clock/test-support", "collections/test-support", "gpui/test-sup anyhow.workspace = true async-recursion = "0.3" async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots"] } +base64.workspace = true chrono = { workspace = true, features = ["serde"] } clock.workspace = true collections.workspace = true @@ -29,6 +30,7 @@ gpui.workspace = true gpui_tokio.workspace = true http_client.workspace = true http_client_tls.workspace = true +httparse = "1.10" log.workspace = true paths.workspace = true parking_lot.workspace = true @@ -69,3 +71,10 @@ windows.workspace = true [target.'cfg(target_os = "macos")'.dependencies] cocoa.workspace = true + +[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] +tokio-native-tls = "0.3" + +[target.'cfg(not(any(target_os = "windows", target_os = "macos")))'.dependencies] +rustls-pki-types = "1.12" +tokio-rustls = { version = "0.26", features = ["tls12", "ring"], default-features = false } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index c5b089809ee691d1b37644c0cb082001bfdf3a64..6d204a32bde3f658b51f44377fc1923b7580c9d9 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1,7 +1,7 @@ #[cfg(any(test, feature = "test-support"))] pub mod test; -mod socks; +mod proxy; pub mod telemetry; pub mod user; pub mod zed_urls; @@ -24,13 +24,13 @@ use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions}; use http_client::{AsyncBody, HttpClient, HttpClientWithUrl}; use parking_lot::RwLock; use postage::watch; +use proxy::connect_proxy_stream; use rand::prelude::*; use release_channel::{AppVersion, ReleaseChannel}; use rpc::proto::{AnyTypedEnvelope, EnvelopedMessage, PeerId, RequestMessage}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsSources}; -use socks::connect_socks_proxy_stream; use std::pin::Pin; use std::{ any::TypeId, @@ -1133,7 +1133,7 @@ impl Client { let handle = cx.update(|cx| gpui_tokio::Tokio::handle(cx)).ok().unwrap(); let _guard = handle.enter(); match proxy { - Some(proxy) => connect_socks_proxy_stream(&proxy, rpc_host).await?, + Some(proxy) => connect_proxy_stream(&proxy, rpc_host).await?, None => Box::new(TcpStream::connect(rpc_host).await?), } }; diff --git a/crates/client/src/proxy.rs b/crates/client/src/proxy.rs new file mode 100644 index 0000000000000000000000000000000000000000..052cfc09f0725f2a40126b803c8daddcaf7c2a2b --- /dev/null +++ b/crates/client/src/proxy.rs @@ -0,0 +1,66 @@ +//! client proxy + +mod http_proxy; +mod socks_proxy; + +use anyhow::{Context as _, Result}; +use http_client::Url; +use http_proxy::{HttpProxyType, connect_http_proxy_stream, parse_http_proxy}; +use socks_proxy::{SocksVersion, connect_socks_proxy_stream, parse_socks_proxy}; + +pub(crate) async fn connect_proxy_stream( + proxy: &Url, + rpc_host: (&str, u16), +) -> Result> { + let Some(((proxy_domain, proxy_port), proxy_type)) = parse_proxy_type(proxy) else { + // If parsing the proxy URL fails, we must avoid falling back to an insecure connection. + // SOCKS proxies are often used in contexts where security and privacy are critical, + // so any fallback could expose users to significant risks. + anyhow::bail!("Parsing proxy url failed"); + }; + + // Connect to proxy and wrap protocol later + let stream = tokio::net::TcpStream::connect((proxy_domain.as_str(), proxy_port)) + .await + .context("Failed to connect to proxy")?; + + let proxy_stream = match proxy_type { + ProxyType::SocksProxy(proxy) => connect_socks_proxy_stream(stream, proxy, rpc_host).await?, + ProxyType::HttpProxy(proxy) => { + connect_http_proxy_stream(stream, proxy, rpc_host, &proxy_domain).await? + } + }; + + Ok(proxy_stream) +} + +enum ProxyType<'t> { + SocksProxy(SocksVersion<'t>), + HttpProxy(HttpProxyType<'t>), +} + +fn parse_proxy_type<'t>(proxy: &'t Url) -> Option<((String, u16), ProxyType<'t>)> { + let scheme = proxy.scheme(); + let host = proxy.host()?.to_string(); + let port = proxy.port_or_known_default()?; + let proxy_type = match scheme { + scheme if scheme.starts_with("socks") => { + Some(ProxyType::SocksProxy(parse_socks_proxy(scheme, proxy))) + } + scheme if scheme.starts_with("http") => { + Some(ProxyType::HttpProxy(parse_http_proxy(scheme, proxy))) + } + _ => None, + }?; + + Some(((host, port), proxy_type)) +} + +pub(crate) trait AsyncReadWrite: + tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static +{ +} +impl AsyncReadWrite + for T +{ +} diff --git a/crates/client/src/proxy/http_proxy.rs b/crates/client/src/proxy/http_proxy.rs new file mode 100644 index 0000000000000000000000000000000000000000..f64c56b16ce527f560e3e71b7e757a1849aa9272 --- /dev/null +++ b/crates/client/src/proxy/http_proxy.rs @@ -0,0 +1,193 @@ +use anyhow::{Context, Result}; +use base64::Engine; +use httparse::{EMPTY_HEADER, Response}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufStream}, + net::TcpStream, +}; +#[cfg(any(target_os = "windows", target_os = "macos"))] +use tokio_native_tls::{TlsConnector, native_tls}; +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +use tokio_rustls::TlsConnector; +use url::Url; + +use super::AsyncReadWrite; + +pub(super) enum HttpProxyType<'t> { + HTTP(Option>), + HTTPS(Option>), +} + +pub(super) struct HttpProxyAuthorization<'t> { + username: &'t str, + password: &'t str, +} + +pub(super) fn parse_http_proxy<'t>(scheme: &str, proxy: &'t Url) -> HttpProxyType<'t> { + let auth = proxy.password().map(|password| HttpProxyAuthorization { + username: proxy.username(), + password, + }); + if scheme.starts_with("https") { + HttpProxyType::HTTPS(auth) + } else { + HttpProxyType::HTTP(auth) + } +} + +pub(crate) async fn connect_http_proxy_stream( + stream: TcpStream, + http_proxy: HttpProxyType<'_>, + rpc_host: (&str, u16), + proxy_domain: &str, +) -> Result> { + match http_proxy { + HttpProxyType::HTTP(auth) => http_connect(stream, rpc_host, auth).await, + HttpProxyType::HTTPS(auth) => https_connect(stream, rpc_host, auth, proxy_domain).await, + } + .context("error connecting to http/https proxy") +} + +async fn http_connect( + stream: T, + target: (&str, u16), + auth: Option>, +) -> Result> +where + T: AsyncReadWrite, +{ + let mut stream = BufStream::new(stream); + let request = make_request(target, auth); + stream.write_all(request.as_bytes()).await?; + stream.flush().await?; + check_response(&mut stream).await?; + Ok(Box::new(stream)) +} + +#[cfg(any(target_os = "windows", target_os = "macos"))] +async fn https_connect( + stream: T, + target: (&str, u16), + auth: Option>, + proxy_domain: &str, +) -> Result> +where + T: AsyncReadWrite, +{ + let tls_connector = TlsConnector::from(native_tls::TlsConnector::new()?); + let stream = tls_connector.connect(proxy_domain, stream).await?; + http_connect(stream, target, auth).await +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +async fn https_connect( + stream: T, + target: (&str, u16), + auth: Option>, + proxy_domain: &str, +) -> Result> +where + T: AsyncReadWrite, +{ + let proxy_domain = rustls_pki_types::ServerName::try_from(proxy_domain) + .context("Address resolution failed")? + .to_owned(); + let tls_connector = TlsConnector::from(std::sync::Arc::new(http_client_tls::tls_config())); + let stream = tls_connector.connect(proxy_domain, stream).await?; + http_connect(stream, target, auth).await +} + +fn make_request(target: (&str, u16), auth: Option>) -> String { + let (host, port) = target; + let mut request = format!( + "CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\nProxy-Connection: Keep-Alive\r\n" + ); + if let Some(HttpProxyAuthorization { username, password }) = auth { + let auth = + base64::prelude::BASE64_STANDARD.encode(format!("{username}:{password}").as_bytes()); + let auth = format!("Proxy-Authorization: Basic {auth}\r\n"); + request.push_str(&auth); + } + request.push_str("\r\n"); + request +} + +async fn check_response(stream: &mut BufStream) -> Result<()> +where + T: AsyncReadWrite, +{ + let response = recv_response(stream).await?; + let mut dummy_headers = [EMPTY_HEADER; MAX_RESPONSE_HEADERS]; + let mut parser = Response::new(&mut dummy_headers); + parser.parse(response.as_bytes())?; + + match parser.code { + Some(code) => { + if code == 200 { + Ok(()) + } else { + Err(anyhow::anyhow!( + "Proxy connection failed with HTTP code: {code}" + )) + } + } + None => Err(anyhow::anyhow!( + "Proxy connection failed with no HTTP code: {}", + parser.reason.unwrap_or("Unknown reason") + )), + } +} + +const MAX_RESPONSE_HEADER_LENGTH: usize = 4096; +const MAX_RESPONSE_HEADERS: usize = 16; + +async fn recv_response(stream: &mut BufStream) -> Result +where + T: AsyncReadWrite, +{ + let mut response = String::new(); + loop { + if stream.read_line(&mut response).await? == 0 { + return Err(anyhow::anyhow!("End of stream")); + } + + if MAX_RESPONSE_HEADER_LENGTH < response.len() { + return Err(anyhow::anyhow!("Maximum response header length exceeded")); + } + + if response.ends_with("\r\n\r\n") { + return Ok(response); + } + } +} + +#[cfg(test)] +mod tests { + use url::Url; + + use super::{HttpProxyAuthorization, HttpProxyType, parse_http_proxy}; + + #[test] + fn test_parse_http_proxy() { + let proxy = Url::parse("http://proxy.example.com:1080").unwrap(); + let scheme = proxy.scheme(); + + let version = parse_http_proxy(scheme, &proxy); + assert!(matches!(version, HttpProxyType::HTTP(None))) + } + + #[test] + fn test_parse_http_proxy_with_auth() { + let proxy = Url::parse("http://username:password@proxy.example.com:1080").unwrap(); + let scheme = proxy.scheme(); + + let version = parse_http_proxy(scheme, &proxy); + assert!(matches!( + version, + HttpProxyType::HTTP(Some(HttpProxyAuthorization { + username: "username", + password: "password" + })) + )) + } +} diff --git a/crates/client/src/socks.rs b/crates/client/src/proxy/socks_proxy.rs similarity index 51% rename from crates/client/src/socks.rs rename to crates/client/src/proxy/socks_proxy.rs index d4b43143adb9340edc586ed49d4790833b307eaa..8ac38e4210920fc39657226822ea55f6f1ead667 100644 --- a/crates/client/src/socks.rs +++ b/crates/client/src/proxy/socks_proxy.rs @@ -1,15 +1,19 @@ //! socks proxy + use anyhow::{Context as _, Result}; use http_client::Url; +use tokio::net::TcpStream; use tokio_socks::tcp::{Socks4Stream, Socks5Stream}; +use super::AsyncReadWrite; + /// Identification to a Socks V4 Proxy -struct Socks4Identification<'a> { +pub(super) struct Socks4Identification<'a> { user_id: &'a str, } /// Authorization to a Socks V5 Proxy -struct Socks5Authorization<'a> { +pub(super) struct Socks5Authorization<'a> { username: &'a str, password: &'a str, } @@ -18,45 +22,50 @@ struct Socks5Authorization<'a> { /// /// V4 allows idenfication using a user_id /// V5 allows authorization using a username and password -enum SocksVersion<'a> { +pub(super) enum SocksVersion<'a> { V4(Option>), V5(Option>), } -pub(crate) async fn connect_socks_proxy_stream( - proxy: &Url, +pub(super) fn parse_socks_proxy<'t>(scheme: &str, proxy: &'t Url) -> SocksVersion<'t> { + if scheme.starts_with("socks4") { + let identification = match proxy.username() { + "" => None, + username => Some(Socks4Identification { user_id: username }), + }; + SocksVersion::V4(identification) + } else { + let authorization = proxy.password().map(|password| Socks5Authorization { + username: proxy.username(), + password, + }); + SocksVersion::V5(authorization) + } +} + +pub(super) async fn connect_socks_proxy_stream( + stream: TcpStream, + socks_version: SocksVersion<'_>, rpc_host: (&str, u16), ) -> Result> { - let Some((socks_proxy, version)) = parse_socks_proxy(proxy) else { - // If parsing the proxy URL fails, we must avoid falling back to an insecure connection. - // SOCKS proxies are often used in contexts where security and privacy are critical, - // so any fallback could expose users to significant risks. - anyhow::bail!("Parsing proxy url failed"); - }; - - // Connect to proxy and wrap protocol later - let stream = tokio::net::TcpStream::connect(socks_proxy) - .await - .context("Failed to connect to socks proxy")?; - - let socks: Box = match version { + match socks_version { SocksVersion::V4(None) => { let socks = Socks4Stream::connect_with_socket(stream, rpc_host) .await .context("error connecting to socks")?; - Box::new(socks) + Ok(Box::new(socks)) } SocksVersion::V4(Some(Socks4Identification { user_id })) => { let socks = Socks4Stream::connect_with_userid_and_socket(stream, rpc_host, user_id) .await .context("error connecting to socks")?; - Box::new(socks) + Ok(Box::new(socks)) } SocksVersion::V5(None) => { let socks = Socks5Stream::connect_with_socket(stream, rpc_host) .await .context("error connecting to socks")?; - Box::new(socks) + Ok(Box::new(socks)) } SocksVersion::V5(Some(Socks5Authorization { username, password })) => { let socks = Socks5Stream::connect_with_password_and_socket( @@ -64,44 +73,9 @@ pub(crate) async fn connect_socks_proxy_stream( ) .await .context("error connecting to socks")?; - Box::new(socks) + Ok(Box::new(socks)) } - }; - - Ok(socks) -} - -fn parse_socks_proxy(proxy: &Url) -> Option<((String, u16), SocksVersion<'_>)> { - let scheme = proxy.scheme(); - let socks_version = if scheme.starts_with("socks4") { - let identification = match proxy.username() { - "" => None, - username => Some(Socks4Identification { user_id: username }), - }; - SocksVersion::V4(identification) - } else if scheme.starts_with("socks") { - let authorization = proxy.password().map(|password| Socks5Authorization { - username: proxy.username(), - password, - }); - SocksVersion::V5(authorization) - } else { - return None; - }; - - let host = proxy.host()?.to_string(); - let port = proxy.port_or_known_default()?; - - Some(((host, port), socks_version)) -} - -pub(crate) trait AsyncReadWrite: - tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static -{ -} -impl AsyncReadWrite - for T -{ + } } #[cfg(test)] @@ -113,20 +87,18 @@ mod tests { #[test] fn parse_socks4() { let proxy = Url::parse("socks4://proxy.example.com:1080").unwrap(); + let scheme = proxy.scheme(); - let ((host, port), version) = parse_socks_proxy(&proxy).unwrap(); - assert_eq!(host, "proxy.example.com"); - assert_eq!(port, 1080); + let version = parse_socks_proxy(scheme, &proxy); assert!(matches!(version, SocksVersion::V4(None))) } #[test] fn parse_socks4_with_identification() { let proxy = Url::parse("socks4://userid@proxy.example.com:1080").unwrap(); + let scheme = proxy.scheme(); - let ((host, port), version) = parse_socks_proxy(&proxy).unwrap(); - assert_eq!(host, "proxy.example.com"); - assert_eq!(port, 1080); + let version = parse_socks_proxy(scheme, &proxy); assert!(matches!( version, SocksVersion::V4(Some(Socks4Identification { user_id: "userid" })) @@ -136,20 +108,18 @@ mod tests { #[test] fn parse_socks5() { let proxy = Url::parse("socks5://proxy.example.com:1080").unwrap(); + let scheme = proxy.scheme(); - let ((host, port), version) = parse_socks_proxy(&proxy).unwrap(); - assert_eq!(host, "proxy.example.com"); - assert_eq!(port, 1080); + let version = parse_socks_proxy(scheme, &proxy); assert!(matches!(version, SocksVersion::V5(None))) } #[test] fn parse_socks5_with_authorization() { let proxy = Url::parse("socks5://username:password@proxy.example.com:1080").unwrap(); + let scheme = proxy.scheme(); - let ((host, port), version) = parse_socks_proxy(&proxy).unwrap(); - assert_eq!(host, "proxy.example.com"); - assert_eq!(port, 1080); + let version = parse_socks_proxy(scheme, &proxy); assert!(matches!( version, SocksVersion::V5(Some(Socks5Authorization { @@ -158,19 +128,4 @@ mod tests { })) )) } - - /// If parsing the proxy URL fails, we must avoid falling back to an insecure connection. - /// SOCKS proxies are often used in contexts where security and privacy are critical, - /// so any fallback could expose users to significant risks. - #[tokio::test] - async fn fails_on_bad_proxy() { - // Should fail connecting because http is not a valid Socks proxy scheme - let proxy = Url::parse("http://localhost:2313").unwrap(); - - let result = connect_socks_proxy_stream(&proxy, ("test", 1080)).await; - match result { - Err(e) => assert_eq!(e.to_string(), "Parsing proxy url failed"), - Ok(_) => panic!("Connecting on bad proxy should fail"), - }; - } }