@@ -51,6 +51,7 @@ struct ConnectDecision {
5151///
5252/// Returned by [`handle_inference_interception`] so the call site can emit
5353/// a structured CONNECT deny log when the connection is not successfully routed.
54+ #[ derive( Debug ) ]
5455enum InferenceOutcome {
5556 /// At least one request was successfully routed to a local inference backend.
5657 Routed ,
@@ -1009,8 +1010,6 @@ async fn handle_inference_interception(
10091010 tls_state : Option < & Arc < ProxyTlsState > > ,
10101011 inference_ctx : Option < & Arc < InferenceContext > > ,
10111012) -> Result < InferenceOutcome > {
1012- use crate :: l7:: inference:: { ParseResult , format_http_response, try_parse_http_request} ;
1013-
10141013 let Some ( ctx) = inference_ctx else {
10151014 return Ok ( InferenceOutcome :: Denied {
10161015 reason : "cluster inference context not configured" . to_string ( ) ,
@@ -1033,15 +1032,28 @@ async fn handle_inference_interception(
10331032 }
10341033 } ;
10351034
1036- // Read and process HTTP requests from the tunnel.
1037- // Track whether any request was successfully routed so that a late denial
1038- // on a keep-alive connection still counts as "routed".
1035+ process_inference_keepalive ( & mut tls_client, ctx, port) . await
1036+ }
1037+
1038+ /// Read and process HTTP requests from a TLS-terminated inference connection.
1039+ ///
1040+ /// Each request is matched against inference patterns and routed locally.
1041+ /// Any non-inference request is immediately denied and the connection is closed,
1042+ /// even if previous requests on the same keep-alive connection were routed
1043+ /// successfully.
1044+ async fn process_inference_keepalive < S : tokio:: io:: AsyncRead + tokio:: io:: AsyncWrite + Unpin > (
1045+ stream : & mut S ,
1046+ ctx : & InferenceContext ,
1047+ port : u16 ,
1048+ ) -> Result < InferenceOutcome > {
1049+ use crate :: l7:: inference:: { ParseResult , format_http_response, try_parse_http_request} ;
1050+
10391051 let mut buf = vec ! [ 0u8 ; INITIAL_INFERENCE_BUF ] ;
10401052 let mut used = 0usize ;
10411053 let mut routed_any = false ;
10421054
10431055 loop {
1044- let n = match tls_client . read ( & mut buf[ used..] ) . await {
1056+ let n = match stream . read ( & mut buf[ used..] ) . await {
10451057 Ok ( n) => n,
10461058 Err ( e) => {
10471059 if routed_any {
@@ -1065,10 +1077,13 @@ async fn handle_inference_interception(
10651077 // Try to parse a complete HTTP request
10661078 match try_parse_http_request ( & buf[ ..used] ) {
10671079 ParseResult :: Complete ( request, consumed) => {
1068- let was_routed = route_inference_request ( & request, ctx, & mut tls_client ) . await ?;
1080+ let was_routed = route_inference_request ( & request, ctx, stream ) . await ?;
10691081 if was_routed {
10701082 routed_any = true ;
1071- } else if !routed_any {
1083+ } else {
1084+ // Deny and close: a non-inference request must not be silently
1085+ // ignored on a keep-alive connection that previously routed
1086+ // inference traffic.
10721087 return Ok ( InferenceOutcome :: Denied {
10731088 reason : "connection not allowed by policy" . to_string ( ) ,
10741089 } ) ;
@@ -1083,7 +1098,7 @@ async fn handle_inference_interception(
10831098 if used == buf. len ( ) {
10841099 if buf. len ( ) >= MAX_INFERENCE_BUF {
10851100 let response = format_http_response ( 413 , & [ ] , b"Payload Too Large" ) ;
1086- write_all ( & mut tls_client , & response) . await ?;
1101+ write_all ( stream , & response) . await ?;
10871102 if routed_any {
10881103 break ;
10891104 }
@@ -1109,7 +1124,7 @@ async fn handle_inference_interception(
11091124 ocsf_emit ! ( event) ;
11101125 }
11111126 let response = format_http_response ( 400 , & [ ] , b"Bad Request" ) ;
1112- write_all ( & mut tls_client , & response) . await ?;
1127+ write_all ( stream , & response) . await ?;
11131128 return Ok ( InferenceOutcome :: Denied { reason } ) ;
11141129 }
11151130 }
@@ -2467,6 +2482,41 @@ mod tests {
24672482 assert ! ( is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: UNSPECIFIED ) ) ) ;
24682483 }
24692484
2485+ #[ test]
2486+ fn test_rejects_ipv4_cgnat ( ) {
2487+ // 100.64.0.0/10 — CGNAT / shared address space (RFC 6598)
2488+ assert ! ( is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new( 100 , 64 , 0 , 1 ) ) ) ) ;
2489+ assert ! ( is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new( 100 , 100 , 50 , 3 ) ) ) ) ;
2490+ assert ! ( is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new(
2491+ 100 , 127 , 255 , 255
2492+ ) ) ) ) ;
2493+ // Just outside the /10 boundary
2494+ assert ! ( !is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new( 100 , 128 , 0 , 1 ) ) ) ) ;
2495+ assert ! ( !is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new(
2496+ 100 , 63 , 255 , 255
2497+ ) ) ) ) ;
2498+ }
2499+
2500+ #[ test]
2501+ fn test_rejects_ipv4_special_use_ranges ( ) {
2502+ // 192.0.0.0/24 — IETF protocol assignments
2503+ assert ! ( is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new( 192 , 0 , 0 , 1 ) ) ) ) ;
2504+ // 198.18.0.0/15 — benchmarking
2505+ assert ! ( is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new( 198 , 18 , 0 , 1 ) ) ) ) ;
2506+ assert ! ( is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new( 198 , 19 , 255 , 255 ) ) ) ) ;
2507+ // 198.51.100.0/24 — TEST-NET-2
2508+ assert ! ( is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new( 198 , 51 , 100 , 1 ) ) ) ) ;
2509+ // 203.0.113.0/24 — TEST-NET-3
2510+ assert ! ( is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new( 203 , 0 , 113 , 1 ) ) ) ) ;
2511+ }
2512+
2513+ #[ test]
2514+ fn test_rejects_ipv6_mapped_cgnat ( ) {
2515+ // ::ffff:100.64.0.1 should be caught via IPv4-mapped unwrapping
2516+ let v6 = Ipv4Addr :: new ( 100 , 64 , 0 , 1 ) . to_ipv6_mapped ( ) ;
2517+ assert ! ( is_internal_ip( IpAddr :: V6 ( v6) ) ) ;
2518+ }
2519+
24702520 #[ test]
24712521 fn test_allows_ipv4_public ( ) {
24722522 assert ! ( !is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new( 8 , 8 , 8 , 8 ) ) ) ) ;
@@ -3355,4 +3405,85 @@ mod tests {
33553405 let result = implicit_allowed_ips_for_ip_host ( "*.example.com" ) ;
33563406 assert ! ( result. is_empty( ) ) ;
33573407 }
3408+
3409+ /// Regression test: exercises the actual keep-alive interception loop to
3410+ /// verify that a non-inference request is denied even after a previous
3411+ /// inference request was successfully routed on the same connection.
3412+ ///
3413+ /// Before the fix, `handle_inference_interception` used
3414+ /// `else if !routed_any` which silently dropped denials once `routed_any`
3415+ /// was true, allowing non-inference HTTP requests to piggyback on a
3416+ /// keep-alive connection that had previously handled inference traffic.
3417+ /// Regression test: exercises the actual keep-alive interception loop to
3418+ /// verify that a non-inference request is denied even after a previous
3419+ /// inference request was successfully routed on the same connection.
3420+ ///
3421+ /// The server runs in a spawned task with empty routes (the inference
3422+ /// request gets a 503 "not configured" but is still recognized as
3423+ /// inference and returns Ok(true)). The client sends the inference
3424+ /// request, reads the 503 response, then sends a non-inference request
3425+ /// on the same connection. The server must return Denied.
3426+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 2 ) ]
3427+ async fn test_keepalive_denies_non_inference_after_routed ( ) {
3428+ use openshell_router:: Router ;
3429+ use tokio:: io:: { AsyncReadExt , AsyncWriteExt } ;
3430+
3431+ let router = Router :: new ( ) . unwrap ( ) ;
3432+ let patterns = crate :: l7:: inference:: default_patterns ( ) ;
3433+ // Empty routes: inference request gets 503 but returns Ok(true).
3434+ let ctx = InferenceContext :: new ( patterns, router, vec ! [ ] , vec ! [ ] ) ;
3435+
3436+ let body = r#"{"model":"test","messages":[{"role":"user","content":"hi"}]}"# ;
3437+ let inference_req = format ! (
3438+ "POST /v1/chat/completions HTTP/1.1\r \n \
3439+ Host: inference.local\r \n \
3440+ Content-Type: application/json\r \n \
3441+ Content-Length: {}\r \n \r \n {}",
3442+ body. len( ) ,
3443+ body,
3444+ ) ;
3445+ let non_inference_req = "GET /admin/config HTTP/1.1\r \n Host: inference.local\r \n \r \n " ;
3446+
3447+ let ( client, mut server) = tokio:: io:: duplex ( 65536 ) ;
3448+ let ( mut client_read, mut client_write) = tokio:: io:: split ( client) ;
3449+
3450+ // Spawn the server task so it runs concurrently.
3451+ let server_task =
3452+ tokio:: spawn ( async move { process_inference_keepalive ( & mut server, & ctx, 443 ) . await } ) ;
3453+
3454+ // Client: send inference request, read response, send non-inference.
3455+ client_write
3456+ . write_all ( inference_req. as_bytes ( ) )
3457+ . await
3458+ . unwrap ( ) ;
3459+
3460+ // Read the 503 response so the server loops back to read.
3461+ let mut buf = vec ! [ 0u8 ; 4096 ] ;
3462+ let _ = client_read. read ( & mut buf) . await . unwrap ( ) ;
3463+
3464+ // Send non-inference request on the same keep-alive connection.
3465+ client_write
3466+ . write_all ( non_inference_req. as_bytes ( ) )
3467+ . await
3468+ . unwrap ( ) ;
3469+ drop ( client_write) ;
3470+
3471+ // Drain remaining response bytes.
3472+ tokio:: spawn ( async move {
3473+ let mut buf = vec ! [ 0u8 ; 4096 ] ;
3474+ loop {
3475+ match client_read. read ( & mut buf) . await {
3476+ Ok ( 0 ) | Err ( _) => break ,
3477+ Ok ( _) => continue ,
3478+ }
3479+ }
3480+ } ) ;
3481+
3482+ let outcome = server_task. await . unwrap ( ) . unwrap ( ) ;
3483+
3484+ assert ! (
3485+ matches!( outcome, InferenceOutcome :: Denied { .. } ) ,
3486+ "expected Denied after non-inference request on keep-alive, got: {outcome:?}"
3487+ ) ;
3488+ }
33583489}
0 commit comments