@@ -52,6 +52,7 @@ impl HttpConnectProxyOptions {
5252 & self ,
5353 uri : tonic:: transport:: Uri ,
5454 ) -> anyhow:: Result < hyper:: upgrade:: Upgraded > {
55+ let uri = ensure_connect_authority_port ( uri) ;
5556 debug ! ( "Connecting to {} via proxy at {}" , uri, self . target_addr) ;
5657 // Create CONNECT request
5758 let mut req_build = hyper:: Request :: builder ( ) . method ( "CONNECT" ) . uri ( uri) ;
@@ -207,3 +208,108 @@ impl Connection for ProxyStream {
207208 }
208209 }
209210}
211+
212+ /// Ensure the URI authority includes an explicit port so that hyper emits a
213+ /// RFC 9110-compliant CONNECT request-target (authority-form requires host:port).
214+ fn ensure_connect_authority_port ( uri : tonic:: transport:: Uri ) -> tonic:: transport:: Uri {
215+ if uri. port ( ) . is_some ( ) {
216+ return uri;
217+ }
218+ let port = match uri. scheme_str ( ) {
219+ Some ( "https" ) => 443 ,
220+ Some ( "http" ) => 80 ,
221+ _ => return uri,
222+ } ;
223+ let mut parts = uri. into_parts ( ) ;
224+ if let Some ( ref authority) = parts. authority
225+ && let Ok ( new_auth) = format ! ( "{}:{}" , authority. host( ) , port) . parse ( )
226+ {
227+ parts. authority = Some ( new_auth) ;
228+ }
229+ tonic:: transport:: Uri :: from_parts ( parts) . expect ( "adding port to valid URI should not fail" )
230+ }
231+
232+ #[ cfg( test) ]
233+ mod tests {
234+ use super :: * ;
235+ use tokio:: {
236+ io:: { AsyncBufReadExt , AsyncWriteExt , BufReader } ,
237+ net:: TcpListener ,
238+ } ;
239+
240+ struct CapturedConnect {
241+ request_line : String ,
242+ headers : Vec < String > ,
243+ }
244+
245+ async fn mock_proxy ( ) -> ( String , tokio:: task:: JoinHandle < CapturedConnect > ) {
246+ let listener = TcpListener :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
247+ let addr = listener. local_addr ( ) . unwrap ( ) . to_string ( ) ;
248+ let handle = tokio:: spawn ( async move {
249+ let ( stream, _) = listener. accept ( ) . await . unwrap ( ) ;
250+ let mut reader = BufReader :: new ( stream) ;
251+ let mut request_line = String :: new ( ) ;
252+ reader. read_line ( & mut request_line) . await . unwrap ( ) ;
253+ let mut headers = Vec :: new ( ) ;
254+ loop {
255+ let mut line = String :: new ( ) ;
256+ reader. read_line ( & mut line) . await . unwrap ( ) ;
257+ if line == "\r \n " {
258+ break ;
259+ }
260+ headers. push ( line. trim_end ( ) . to_string ( ) ) ;
261+ }
262+ reader
263+ . into_inner ( )
264+ . write_all ( b"HTTP/1.1 200 OK\r \n \r \n " )
265+ . await
266+ . unwrap ( ) ;
267+ CapturedConnect {
268+ request_line,
269+ headers,
270+ }
271+ } ) ;
272+ ( addr, handle)
273+ }
274+
275+ #[ rstest:: rstest]
276+ #[ case( "https://example.com/some/path" , "CONNECT example.com:443 HTTP/1.1" ) ]
277+ #[ case( "http://example.com" , "CONNECT example.com:80 HTTP/1.1" ) ]
278+ #[ case( "https://example.com:7233" , "CONNECT example.com:7233 HTTP/1.1" ) ]
279+ #[ tokio:: test]
280+ async fn connect_request_line ( #[ case] uri : & str , #[ case] expected : & str ) {
281+ let ( proxy_addr, handle) = mock_proxy ( ) . await ;
282+ let opts = HttpConnectProxyOptions {
283+ target_addr : proxy_addr,
284+ basic_auth : None ,
285+ } ;
286+ let uri: tonic:: transport:: Uri = uri. parse ( ) . unwrap ( ) ;
287+ let _ = opts. connect ( uri) . await ;
288+
289+ let captured = handle. await . unwrap ( ) ;
290+ assert_eq ! ( captured. request_line. trim( ) , expected) ;
291+ }
292+
293+ #[ tokio:: test]
294+ async fn connect_includes_basic_auth ( ) {
295+ let ( proxy_addr, handle) = mock_proxy ( ) . await ;
296+ let opts = HttpConnectProxyOptions {
297+ target_addr : proxy_addr,
298+ basic_auth : Some ( ( "user" . to_string ( ) , "pass" . to_string ( ) ) ) ,
299+ } ;
300+ let uri: tonic:: transport:: Uri = "https://example.com:7233" . parse ( ) . unwrap ( ) ;
301+ let _ = opts. connect ( uri) . await ;
302+
303+ let captured = handle. await . unwrap ( ) ;
304+ let creds = BASE64_STANDARD . encode ( "user:pass" ) ;
305+ let auth_header = captured
306+ . headers
307+ . iter ( )
308+ . find ( |h| h. to_lowercase ( ) . starts_with ( "proxy-authorization:" ) )
309+ . expect ( "missing proxy-authorization header" ) ;
310+ assert_eq ! (
311+ auth_header. trim( ) ,
312+ format!( "proxy-authorization: Basic {creds}" )
313+ ) ;
314+ }
315+ }
0 commit comments