@@ -758,6 +758,54 @@ async fn http_plain_proxy(
758758 }
759759 }
760760
761+ let host_mitm_requirement = match app_state. host_mitm_requirement ( & host) . await {
762+ Ok ( requirement) => requirement,
763+ Err ( err) => {
764+ return Ok ( internal_error ( "failed to inspect MITM requirements" , err) ) ;
765+ }
766+ } ;
767+ if host_mitm_requirement == HostMitmRequirement :: Always {
768+ emit_http_block_decision_audit_event (
769+ & app_state,
770+ BlockDecisionAuditEventArgs {
771+ source : NetworkDecisionSource :: ModeGuard ,
772+ reason : REASON_MITM_REQUIRED ,
773+ protocol : NetworkProtocol :: Http ,
774+ server_address : host. as_str ( ) ,
775+ server_port : port,
776+ method : Some ( req. method ( ) . as_str ( ) ) ,
777+ client_addr : client. as_deref ( ) ,
778+ } ,
779+ ) ;
780+ let details = PolicyDecisionDetails {
781+ decision : NetworkPolicyDecision :: Deny ,
782+ reason : REASON_MITM_REQUIRED ,
783+ source : NetworkDecisionSource :: ModeGuard ,
784+ protocol : NetworkProtocol :: Http ,
785+ host : & host,
786+ port,
787+ } ;
788+ let _ = app_state
789+ . record_blocked ( BlockedRequest :: new ( BlockedRequestArgs {
790+ host : host. clone ( ) ,
791+ reason : REASON_MITM_REQUIRED . to_string ( ) ,
792+ client : client. clone ( ) ,
793+ method : Some ( req. method ( ) . as_str ( ) . to_string ( ) ) ,
794+ mode : None ,
795+ protocol : "http" . to_string ( ) ,
796+ decision : Some ( details. decision . as_str ( ) . to_string ( ) ) ,
797+ source : Some ( details. source . as_str ( ) . to_string ( ) ) ,
798+ port : Some ( port) ,
799+ } ) )
800+ . await ;
801+ let client = client. as_deref ( ) . unwrap_or_default ( ) ;
802+ warn ! (
803+ "request blocked; MITM required to enforce host policy (client={client}, host={host}, method={})" ,
804+ req. method( )
805+ ) ;
806+ return Ok ( json_blocked ( & host, REASON_MITM_REQUIRED , Some ( & details) ) ) ;
807+ }
808+
761809 if !method_allowed {
762810 emit_http_block_decision_audit_event (
763811 & app_state,
@@ -1418,6 +1466,87 @@ mod tests {
14181466 target_task. await . expect ( "target task should finish" ) ;
14191467 }
14201468
1469+ #[ tokio:: test]
1470+ async fn http_proxy_blocks_absolute_form_https_for_hooked_host ( ) {
1471+ let target_listener = TokioTcpListener :: bind ( ( Ipv4Addr :: LOCALHOST , 0 ) )
1472+ . await
1473+ . expect ( "target listener should bind" ) ;
1474+ let target_addr = target_listener
1475+ . local_addr ( )
1476+ . expect ( "target listener should expose local addr" ) ;
1477+ let target_task = tokio:: spawn ( async move {
1478+ timeout ( Duration :: from_secs ( 1 ) , target_listener. accept ( ) )
1479+ . await
1480+ . is_ok ( )
1481+ } ) ;
1482+
1483+ let state = Arc :: new ( network_proxy_state_for_policy ( {
1484+ let mut network = NetworkProxyConfig {
1485+ allow_local_binding : true ,
1486+ mitm : true ,
1487+ mitm_hooks : vec ! [ crate :: mitm_hook:: MitmHookConfig {
1488+ host: "127.0.0.1" . to_string( ) ,
1489+ matcher: crate :: mitm_hook:: MitmHookMatchConfig {
1490+ methods: vec![ "GET" . to_string( ) ] ,
1491+ path_prefixes: vec![ "/repos/openai/ALLOWED" . to_string( ) ] ,
1492+ ..crate :: mitm_hook:: MitmHookMatchConfig :: default ( )
1493+ } ,
1494+ actions: crate :: mitm_hook:: MitmHookActionsConfig :: default ( ) ,
1495+ } ] ,
1496+ ..NetworkProxyConfig :: default ( )
1497+ } ;
1498+ network. set_allowed_domains ( vec ! [ "127.0.0.1" . to_string( ) ] ) ;
1499+ network
1500+ } ) ) ;
1501+ let listener =
1502+ StdTcpListener :: bind ( ( Ipv4Addr :: LOCALHOST , 0 ) ) . expect ( "proxy listener should bind" ) ;
1503+ let proxy_addr = listener
1504+ . local_addr ( )
1505+ . expect ( "proxy listener should expose local addr" ) ;
1506+ let proxy_task = tokio:: spawn ( run_http_proxy_with_std_listener (
1507+ state. clone ( ) ,
1508+ listener,
1509+ /*policy_decider*/ None ,
1510+ /*environment_id*/ None ,
1511+ ) ) ;
1512+
1513+ let mut stream = tokio:: net:: TcpStream :: connect ( proxy_addr)
1514+ . await
1515+ . expect ( "client should connect to proxy" ) ;
1516+ let request = format ! (
1517+ "GET https://127.0.0.1:{port}/repos/openai/UNAUTHORIZED HTTP/1.1\r \n Host: 127.0.0.1:{port}\r \n Connection: close\r \n \r \n " ,
1518+ port = target_addr. port( )
1519+ ) ;
1520+ stream
1521+ . write_all ( request. as_bytes ( ) )
1522+ . await
1523+ . expect ( "client should write absolute-form HTTPS request" ) ;
1524+
1525+ let mut buf = [ 0_u8 ; 512 ] ;
1526+ let bytes_read = timeout ( Duration :: from_secs ( 2 ) , stream. read ( & mut buf) )
1527+ . await
1528+ . expect ( "proxy should respond before timeout" )
1529+ . expect ( "client should read proxy response" ) ;
1530+ let response = String :: from_utf8_lossy ( & buf[ ..bytes_read] ) ;
1531+ assert ! (
1532+ response. starts_with( "HTTP/1.1 403 Forbidden\r \n " ) ,
1533+ "unexpected proxy response: {response:?}"
1534+ ) ;
1535+ assert ! ( response. contains( "x-proxy-error: blocked-by-mitm-required\r \n " ) ) ;
1536+ assert ! (
1537+ !target_task. await . expect( "target task should finish" ) ,
1538+ "blocked request must not reach upstream"
1539+ ) ;
1540+
1541+ let blocked = state. drain_blocked ( ) . await . unwrap ( ) ;
1542+ assert_eq ! ( blocked. len( ) , 1 ) ;
1543+ assert_eq ! ( blocked[ 0 ] . reason, REASON_MITM_REQUIRED ) ;
1544+
1545+ drop ( stream) ;
1546+ proxy_task. abort ( ) ;
1547+ let _ = proxy_task. await ;
1548+ }
1549+
14211550 #[ tokio:: test( flavor = "current_thread" ) ]
14221551 async fn http_plain_proxy_blocks_unix_socket_when_method_not_allowed ( ) {
14231552 let state = Arc :: new ( network_proxy_state_for_policy ( NetworkProxyConfig :: default ( ) ) ) ;
0 commit comments