@@ -614,6 +614,23 @@ async fn accept_loop(
614614 }
615615}
616616
617+ /// Normalise a CONNECT authority to lowercase `host:port`, defaulting the port
618+ /// to 443 when absent. Handles IPv6 brackets: `[::1]:443` already has a port,
619+ /// `[::1]` needs the default, `host:443` has a port.
620+ fn normalize_authority ( authority : & str ) -> String {
621+ if authority. starts_with ( '[' ) {
622+ if authority. contains ( "]:" ) {
623+ authority. to_lowercase ( )
624+ } else {
625+ format ! ( "{}:443" , authority. to_lowercase( ) )
626+ }
627+ } else if authority. contains ( ':' ) {
628+ authority. to_lowercase ( )
629+ } else {
630+ format ! ( "{}:443" , authority. to_lowercase( ) )
631+ }
632+ }
633+
617634/// Handle a single client connection.
618635///
619636/// Reads the first HTTP line to determine the proxy mode:
@@ -677,19 +694,7 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState
677694 if !state. route_store . is_empty ( )
678695 && let Some ( authority) = first_line. split_whitespace ( ) . nth ( 1 )
679696 {
680- // Normalise authority to host:port. Handle IPv6 brackets:
681- // "[::1]:443" already has port, "[::1]" needs default, "host:443" has port.
682- let host_port = if authority. starts_with ( '[' ) {
683- if authority. contains ( "]:" ) {
684- authority. to_lowercase ( )
685- } else {
686- format ! ( "{}:443" , authority. to_lowercase( ) )
687- }
688- } else if authority. contains ( ':' ) {
689- authority. to_lowercase ( )
690- } else {
691- format ! ( "{}:443" , authority. to_lowercase( ) )
692- } ;
697+ let host_port = normalize_authority ( authority) ;
693698
694699 if state. route_store . is_route_upstream ( & host_port) {
695700 let route_id = state
@@ -710,36 +715,89 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState
710715 // (we mint a leaf cert and decrypt traffic), so
711716 // unlike the lenient transparent-tunnel path we
712717 // require Proxy-Authorization here.
713- if let Err ( e) =
714- token:: validate_proxy_auth ( & header_bytes, & state. session_token )
715- {
716- debug ! (
717- "tls_intercept: rejecting CONNECT to {}:{} — {}" ,
718- host, port, e
719- ) ;
720- audit:: log_denied (
721- Some ( & state. audit_log ) ,
722- audit:: ProxyMode :: ConnectIntercept ,
723- & audit:: EventContext {
724- route_id,
725- auth_mechanism : Some (
726- nono:: undo:: NetworkAuditAuthMechanism :: ProxyAuthorization ,
727- ) ,
728- auth_outcome : Some (
729- nono:: undo:: NetworkAuditAuthOutcome :: Failed ,
730- ) ,
731- denial_category : Some (
732- nono:: undo:: NetworkAuditDenialCategory :: AuthenticationFailed ,
733- ) ,
734- ..audit:: EventContext :: default ( )
735- } ,
736- & host,
737- port,
738- "proxy auth missing or invalid" ,
739- ) ;
740- let response = "HTTP/1.1 407 Proxy Authentication Required\r \n Proxy-Authenticate: Basic realm=\" nono\" \r \n Content-Length: 0\r \n \r \n " ;
741- stream. write_all ( response. as_bytes ( ) ) . await ?;
742- return Ok ( ( ) ) ;
718+ // Reactive proxy auth (RFC 7235 / RFC 9110 §15.5.8): a
719+ // client may send the first CONNECT without credentials,
720+ // receive the 407 challenge, then retry the CONNECT with
721+ // Proxy-Authorization on the SAME connection. Keep the
722+ // connection open across the 407 and re-read the retried
723+ // request head rather than dropping the socket — closing
724+ // it breaks reactive clients (Apache HttpClient, Java's
725+ // HttpClient, Maven's native resolver).
726+ let mut current_headers = header_bytes;
727+ loop {
728+ match token:: validate_proxy_auth ( & current_headers, & state. session_token )
729+ {
730+ Ok ( ( ) ) => break ,
731+ Err ( e) => {
732+ debug ! (
733+ "tls_intercept: CONNECT to {}:{} missing/invalid proxy auth — {}" ,
734+ host, port, e
735+ ) ;
736+ audit:: log_denied (
737+ Some ( & state. audit_log ) ,
738+ audit:: ProxyMode :: ConnectIntercept ,
739+ & audit:: EventContext {
740+ route_id,
741+ auth_mechanism : Some (
742+ nono:: undo:: NetworkAuditAuthMechanism :: ProxyAuthorization ,
743+ ) ,
744+ auth_outcome : Some (
745+ nono:: undo:: NetworkAuditAuthOutcome :: Failed ,
746+ ) ,
747+ denial_category : Some (
748+ nono:: undo:: NetworkAuditDenialCategory :: AuthenticationFailed ,
749+ ) ,
750+ ..audit:: EventContext :: default ( )
751+ } ,
752+ & host,
753+ port,
754+ "proxy auth missing or invalid" ,
755+ ) ;
756+ let response = "HTTP/1.1 407 Proxy Authentication Required\r \n Proxy-Authenticate: Basic realm=\" nono\" \r \n Content-Length: 0\r \n \r \n " ;
757+ stream. write_all ( response. as_bytes ( ) ) . await ?;
758+
759+ // Read the client's retried request head on
760+ // the same connection.
761+ let mut buf_reader = BufReader :: new ( & mut stream) ;
762+ let mut retry_line = String :: new ( ) ;
763+ buf_reader. read_line ( & mut retry_line) . await ?;
764+ if retry_line. is_empty ( ) {
765+ return Ok ( ( ) ) ; // client disconnected
766+ }
767+ let mut retry_headers = Vec :: new ( ) ;
768+ loop {
769+ let mut line = String :: new ( ) ;
770+ let n = buf_reader. read_line ( & mut line) . await ?;
771+ if n == 0 || line. trim ( ) . is_empty ( ) {
772+ break ;
773+ }
774+ retry_headers. extend_from_slice ( line. as_bytes ( ) ) ;
775+ if retry_headers. len ( ) > MAX_HEADER_SIZE {
776+ drop ( buf_reader) ;
777+ let too_large = "HTTP/1.1 431 Request Header Fields Too Large\r \n \r \n " ;
778+ stream. write_all ( too_large. as_bytes ( ) ) . await ?;
779+ return Ok ( ( ) ) ;
780+ }
781+ }
782+ drop ( buf_reader) ;
783+
784+ // host/port/route are reused from the first
785+ // CONNECT, so the retry must target the same
786+ // authority; anything else (or a non-CONNECT
787+ // request) would desync routing.
788+ let same_authority = retry_line
789+ . trim_end ( )
790+ . strip_prefix ( "CONNECT " )
791+ . and_then ( |rest| rest. split_whitespace ( ) . next ( ) )
792+ . map ( normalize_authority)
793+ . as_deref ( )
794+ == Some ( host_port. as_str ( ) ) ;
795+ if !same_authority {
796+ return Ok ( ( ) ) ;
797+ }
798+ current_headers = retry_headers;
799+ }
800+ }
743801 }
744802
745803 let ctx = tls_intercept:: InterceptCtx {
@@ -874,6 +932,26 @@ async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState
874932mod tests {
875933 use super :: * ;
876934
935+ #[ test]
936+ fn normalize_authority_normalises_case_and_default_port ( ) {
937+ assert_eq ! ( normalize_authority( "API.OpenAI.com" ) , "api.openai.com:443" ) ;
938+ assert_eq ! (
939+ normalize_authority( "api.openai.com:443" ) ,
940+ "api.openai.com:443"
941+ ) ;
942+ assert_eq ! (
943+ normalize_authority( "api.openai.com:8443" ) ,
944+ "api.openai.com:8443"
945+ ) ;
946+ assert_eq ! ( normalize_authority( "[::1]" ) , "[::1]:443" ) ;
947+ assert_eq ! ( normalize_authority( "[::1]:8443" ) , "[::1]:8443" ) ;
948+ // case- and port-insensitive equality is the point of the retry guard
949+ assert_eq ! (
950+ normalize_authority( "API.OPENAI.COM:443" ) ,
951+ normalize_authority( "api.openai.com" )
952+ ) ;
953+ }
954+
877955 #[ tokio:: test]
878956 async fn test_proxy_starts_and_binds ( ) {
879957 let config = ProxyConfig :: default ( ) ;
@@ -1691,4 +1769,92 @@ mod tests {
16911769
16921770 handle. shutdown ( ) ;
16931771 }
1772+
1773+ /// Regression test for reactive proxy auth on the intercept CONNECT path.
1774+ /// After a 407 the proxy must keep the connection open and answer the
1775+ /// client's credentialed retry on the same socket, rather than closing it
1776+ /// (which breaks reactive clients such as Apache HttpClient / Maven's
1777+ /// native resolver).
1778+ #[ tokio:: test]
1779+ async fn reactive_proxy_auth_retry_answered_after_407 ( ) {
1780+ use base64:: Engine ;
1781+ use std:: time:: Duration ;
1782+ use tokio:: io:: { AsyncReadExt , AsyncWriteExt } ;
1783+ use tokio:: net:: TcpStream ;
1784+
1785+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1786+ let config = ProxyConfig {
1787+ routes : vec ! [ crate :: config:: RouteConfig {
1788+ prefix: "openai" . to_string( ) ,
1789+ upstream: "https://api.openai.com" . to_string( ) ,
1790+ credential_key: Some ( "env://NONO_TEST_TOTALLY_MISSING" . to_string( ) ) ,
1791+ inject_mode: Default :: default ( ) ,
1792+ inject_header: "Authorization" . to_string( ) ,
1793+ credential_format: Some ( "Bearer {}" . to_string( ) ) ,
1794+ path_pattern: None ,
1795+ path_replacement: None ,
1796+ query_param_name: None ,
1797+ proxy: None ,
1798+ env_var: None ,
1799+ endpoint_rules: vec![ ] ,
1800+ tls_ca: None ,
1801+ tls_client_cert: None ,
1802+ tls_client_key: None ,
1803+ oauth2: None ,
1804+ } ] ,
1805+ intercept_ca_dir : Some ( dir. path ( ) . to_path_buf ( ) ) ,
1806+ ..Default :: default ( )
1807+ } ;
1808+ let handle = start ( config) . await . unwrap ( ) ;
1809+ assert ! (
1810+ handle. intercept_ca_path( ) . is_some( ) ,
1811+ "precondition: interception must be active so the 407 path is reached"
1812+ ) ;
1813+ let port = handle. port ;
1814+ let token = handle. token . to_string ( ) ;
1815+
1816+ let mut sock = TcpStream :: connect ( ( "127.0.0.1" , port) ) . await . unwrap ( ) ;
1817+
1818+ // 1) Unauthenticated CONNECT -> expect a 407 challenge.
1819+ sock. write_all ( b"CONNECT api.openai.com:443 HTTP/1.1\r \n Host: api.openai.com:443\r \n \r \n " )
1820+ . await
1821+ . unwrap ( ) ;
1822+ sock. flush ( ) . await . unwrap ( ) ;
1823+
1824+ let mut buf = [ 0u8 ; 4096 ] ;
1825+ let n = sock. read ( & mut buf) . await . unwrap ( ) ;
1826+ let response = String :: from_utf8_lossy ( & buf[ ..n] ) ;
1827+ assert ! (
1828+ response. starts_with( "HTTP/1.1 407 " ) ,
1829+ "expected 407 challenge, got: {:?}" ,
1830+ response
1831+ ) ;
1832+
1833+ // 2) Reactive retry WITH valid credentials on the SAME socket.
1834+ let creds = base64:: engine:: general_purpose:: STANDARD . encode ( format ! ( "nono:{}" , token) ) ;
1835+ let retry = format ! (
1836+ "CONNECT api.openai.com:443 HTTP/1.1\r \n Host: api.openai.com:443\r \n Proxy-Authorization: Basic {}\r \n \r \n " ,
1837+ creds
1838+ ) ;
1839+ sock. write_all ( retry. as_bytes ( ) ) . await . unwrap ( ) ;
1840+ sock. flush ( ) . await . unwrap ( ) ;
1841+
1842+ // 3) The proxy must answer the retried CONNECT on the same socket
1843+ // instead of returning EOF. (The upstream connect to api.openai.com
1844+ // may fail in the test env, so we require a response, not a 200.)
1845+ let mut retry_buf = [ 0u8 ; 4096 ] ;
1846+ let read_result =
1847+ tokio:: time:: timeout ( Duration :: from_secs ( 5 ) , sock. read ( & mut retry_buf) ) . await ;
1848+ match read_result {
1849+ Ok ( Ok ( 0 ) ) => panic ! (
1850+ "regression: proxy closed the socket after the 407 instead of \
1851+ answering the reactive retry"
1852+ ) ,
1853+ Ok ( Ok ( _) ) => { } // answered -> reactive auth handled
1854+ Ok ( Err ( e) ) => panic ! ( "retry read errored: {e}" ) ,
1855+ Err ( _) => panic ! ( "retry read timed out — proxy did not answer the retry" ) ,
1856+ }
1857+
1858+ handle. shutdown ( ) ;
1859+ }
16941860}
0 commit comments