@@ -778,11 +778,18 @@ pub(crate) enum MiddlewareApplyResult {
778778 Denied ( String ) ,
779779}
780780
781+ /// Smallest body-buffering limit across the entries that actually resolved to a
782+ /// registered binding. Unresolved entries (`is_resolved() == false`) report a
783+ /// zero limit and are excluded here: they are handled by their `on_error` policy
784+ /// in `evaluate_described` without inspecting the body, so letting a zero drag
785+ /// the chain limit to zero would spuriously fail the whole chain over capacity.
786+ /// Returns `None` when no entry resolved, so the caller can skip buffering.
781787fn middleware_chain_body_limit (
782788 chain : & [ openshell_supervisor_middleware:: DescribedChainEntry ] ,
783789) -> Option < usize > {
784790 chain
785791 . iter ( )
792+ . filter ( |entry| entry. is_resolved ( ) )
786793 . map ( openshell_supervisor_middleware:: DescribedChainEntry :: max_body_bytes)
787794 . min ( )
788795}
@@ -812,8 +819,20 @@ pub(crate) async fn apply_middleware_chain_for_scheme<C: AsyncRead + AsyncWrite
812819 return Ok ( MiddlewareApplyResult :: Allowed ( req) ) ;
813820 }
814821 let chain = runner. describe_chain ( & chain) . await ?;
815- let max_body_bytes =
816- middleware_chain_body_limit ( & chain) . expect ( "non-empty middleware chain has a body limit" ) ;
822+ let Some ( max_body_bytes) = middleware_chain_body_limit ( & chain) else {
823+ // No entry resolved to a registered binding, so nothing inspects the
824+ // body. Apply each entry's `on_error` policy without buffering (an
825+ // unresolved binding is handled before the body is read) and forward
826+ // the original request unchanged if the chain allows.
827+ let input = middleware_request_input ( scheme, & req, ctx, BTreeMap :: new ( ) , String :: new ( ) , Vec :: new ( ) ) ;
828+ let outcome = runner. evaluate_described ( & chain, input) . await ?;
829+ emit_middleware_events ( ctx, & req, & outcome) ;
830+ return Ok ( if outcome. allowed {
831+ MiddlewareApplyResult :: Allowed ( req)
832+ } else {
833+ MiddlewareApplyResult :: Denied ( outcome. reason )
834+ } ) ;
835+ } ;
817836 let buffered = match crate :: l7:: rest:: buffer_request_body_for_middleware (
818837 & req,
819838 client,
@@ -961,11 +980,17 @@ fn middleware_network_input(ctx: &L7EvalContext) -> crate::opa::NetworkInput {
961980 }
962981}
963982
964- fn emit_middleware_events (
983+ /// Build the OCSF events describing a middleware chain outcome, in emission
984+ /// order. Separated from `emit_middleware_events` so tests can assert on the
985+ /// events deterministically without routing through the global tracing pipeline,
986+ /// whose callsite-interest cache is process-global and races under parallel
987+ /// tests.
988+ fn middleware_events (
965989 ctx : & L7EvalContext ,
966990 req : & crate :: l7:: provider:: L7Request ,
967991 outcome : & openshell_supervisor_middleware:: ChainOutcome ,
968- ) {
992+ ) -> Vec < openshell_ocsf:: OcsfEvent > {
993+ let mut events = Vec :: new ( ) ;
969994 for invocation in & outcome. applied {
970995 let allowed = invocation. decision == openshell_core:: proto:: Decision :: Allow ;
971996 let event = HttpActivityBuilder :: new ( openshell_ocsf:: ctx:: ctx ( ) )
@@ -1000,7 +1025,7 @@ fn emit_middleware_events(
10001025 invocation. failed
10011026 ) )
10021027 . build ( ) ;
1003- ocsf_emit ! ( event) ;
1028+ events . push ( event) ;
10041029
10051030 // A middleware that failed but was bypassed under `fail_open` is an
10061031 // enforcement failure operators must be able to alert on, even though the
@@ -1021,7 +1046,7 @@ fn emit_middleware_events(
10211046 invocation. name
10221047 ) )
10231048 . build ( ) ;
1024- ocsf_emit ! ( event) ;
1049+ events . push ( event) ;
10251050 }
10261051 }
10271052 if !outcome. allowed && outcome. reason . starts_with ( "middleware_failed:" ) {
@@ -1033,7 +1058,7 @@ fn emit_middleware_events(
10331058 ) )
10341059 . message ( "Required supervisor middleware failed closed" )
10351060 . build ( ) ;
1036- ocsf_emit ! ( event) ;
1061+ events . push ( event) ;
10371062 }
10381063 for finding in & outcome. findings {
10391064 let event = DetectionFindingBuilder :: new ( openshell_ocsf:: ctx:: ctx ( ) )
@@ -1055,6 +1080,19 @@ fn emit_middleware_events(
10551080 finding. finding. r#type, finding. finding. count
10561081 ) )
10571082 . build ( ) ;
1083+ events. push ( event) ;
1084+ }
1085+ events
1086+ }
1087+
1088+ /// Emit the OCSF events describing a middleware chain outcome through the
1089+ /// tracing pipeline.
1090+ fn emit_middleware_events (
1091+ ctx : & L7EvalContext ,
1092+ req : & crate :: l7:: provider:: L7Request ,
1093+ outcome : & openshell_supervisor_middleware:: ChainOutcome ,
1094+ ) {
1095+ for event in middleware_events ( ctx, req, outcome) {
10581096 ocsf_emit ! ( event) ;
10591097 }
10601098}
@@ -3051,6 +3089,101 @@ network_policies:
30513089 ) ) ;
30523090 }
30533091
3092+ #[ tokio:: test]
3093+ async fn body_limit_ignores_unresolved_entries ( ) {
3094+ use openshell_supervisor_middleware:: { ChainEntry , ChainRunner , OnError } ;
3095+
3096+ let resolved = ChainEntry {
3097+ name : "redact" . into ( ) ,
3098+ implementation : openshell_supervisor_middleware:: BUILTIN_SECRETS . into ( ) ,
3099+ config : prost_types:: Struct :: default ( ) ,
3100+ on_error : OnError :: FailClosed ,
3101+ } ;
3102+ let unresolved = ChainEntry {
3103+ name : "missing" . into ( ) ,
3104+ implementation : "third-party/missing" . into ( ) ,
3105+ config : prost_types:: Struct :: default ( ) ,
3106+ on_error : OnError :: FailOpen ,
3107+ } ;
3108+
3109+ // A single unresolved (0-limit) entry must not drag the chain limit to
3110+ // zero: the buffer limit reflects only the resolved built-in.
3111+ let mixed = ChainRunner :: default ( )
3112+ . describe_chain ( & [ resolved, unresolved. clone ( ) ] )
3113+ . await
3114+ . expect ( "describe mixed chain" ) ;
3115+ assert_eq ! ( middleware_chain_body_limit( & mixed) , Some ( 256 * 1024 ) ) ;
3116+
3117+ // When nothing resolves, there is no body limit and the caller skips
3118+ // buffering entirely.
3119+ let none = ChainRunner :: default ( )
3120+ . describe_chain ( std:: slice:: from_ref ( & unresolved) )
3121+ . await
3122+ . expect ( "describe unresolved chain" ) ;
3123+ assert_eq ! ( middleware_chain_body_limit( & none) , None ) ;
3124+ }
3125+
3126+ #[ tokio:: test]
3127+ async fn all_unresolved_fail_open_forwards_body_unbuffered ( ) {
3128+ // A chain whose only entry is an unregistered binding has no resolvable
3129+ // body limit. Under fail_open the request must pass through with its
3130+ // body intact rather than being denied over a phantom zero-byte cap.
3131+ let ( config, tunnel_engine, ctx) =
3132+ middleware_relay_context ( "third-party/missing" , "fail_open" ) ;
3133+ let ( mut app, mut relay_client) = tokio:: io:: duplex ( 8192 ) ;
3134+ let ( mut relay_upstream, mut upstream) = tokio:: io:: duplex ( 8192 ) ;
3135+ let relay = tokio:: spawn ( async move {
3136+ relay_with_inspection (
3137+ & config,
3138+ tunnel_engine,
3139+ & mut relay_client,
3140+ & mut relay_upstream,
3141+ & ctx,
3142+ )
3143+ . await
3144+ } ) ;
3145+
3146+ let body = br#"{"api_key":"sk-1234567890abcdef"}"# ;
3147+ let request = format ! (
3148+ "POST /v1/messages HTTP/1.1\r \n Host: api.example.test\r \n Content-Length: {}\r \n Connection: close\r \n \r \n {}" ,
3149+ body. len( ) ,
3150+ std:: str :: from_utf8( body) . unwrap( )
3151+ ) ;
3152+ app. write_all ( request. as_bytes ( ) ) . await . unwrap ( ) ;
3153+
3154+ let mut upstream_request = [ 0u8 ; 1024 ] ;
3155+ let n = tokio:: time:: timeout (
3156+ std:: time:: Duration :: from_secs ( 1 ) ,
3157+ upstream. read ( & mut upstream_request) ,
3158+ )
3159+ . await
3160+ . expect ( "request should reach upstream" )
3161+ . unwrap ( ) ;
3162+ let upstream_request = String :: from_utf8_lossy ( & upstream_request[ ..n] ) ;
3163+ // No middleware ran, so the body is forwarded verbatim.
3164+ assert ! ( upstream_request. contains( r#""api_key":"sk-1234567890abcdef""# ) ) ;
3165+
3166+ upstream
3167+ . write_all ( b"HTTP/1.1 204 No Content\r \n Content-Length: 0\r \n Connection: close\r \n \r \n " )
3168+ . await
3169+ . unwrap ( ) ;
3170+ let mut client_response = [ 0u8 ; 512 ] ;
3171+ let n = tokio:: time:: timeout (
3172+ std:: time:: Duration :: from_secs ( 1 ) ,
3173+ app. read ( & mut client_response) ,
3174+ )
3175+ . await
3176+ . expect ( "response should reach client" )
3177+ . unwrap ( ) ;
3178+ assert ! ( String :: from_utf8_lossy( & client_response[ ..n] ) . contains( "204 No Content" ) ) ;
3179+ drop ( app) ;
3180+ tokio:: time:: timeout ( std:: time:: Duration :: from_secs ( 1 ) , relay)
3181+ . await
3182+ . expect ( "relay should finish" )
3183+ . unwrap ( )
3184+ . unwrap ( ) ;
3185+ }
3186+
30543187 #[ test]
30553188 fn middleware_keeps_the_raw_request_query ( ) {
30563189 let query = raw_query_from_request_headers (
@@ -3095,36 +3228,14 @@ network_policies:
30953228 assert_eq ! ( input. scheme, "http" ) ;
30963229 }
30973230
3098- /// Tracing layer that captures emitted `OcsfEvent`s for assertions.
3099- struct OcsfCaptureLayer ( Arc < std:: sync:: Mutex < Vec < openshell_ocsf:: OcsfEvent > > > ) ;
3100-
3101- impl < S : tracing:: Subscriber > tracing_subscriber:: Layer < S > for OcsfCaptureLayer {
3102- fn on_event (
3103- & self ,
3104- event : & tracing:: Event < ' _ > ,
3105- _ctx : tracing_subscriber:: layer:: Context < ' _ , S > ,
3106- ) {
3107- if event. metadata ( ) . target ( ) == openshell_ocsf:: OCSF_TARGET
3108- && let Some ( ocsf_event) = openshell_ocsf:: clone_current_event ( )
3109- {
3110- self . 0 . lock ( ) . unwrap ( ) . push ( ocsf_event) ;
3111- }
3112- }
3113- }
3114-
31153231 #[ test]
31163232 fn middleware_ocsf_events_are_audit_safe ( ) {
31173233 use openshell_supervisor_middleware:: {
31183234 ChainOutcome , MiddlewareInvocation , NamespacedFinding ,
31193235 } ;
3120- use tracing_subscriber:: layer:: SubscriberExt ;
31213236
31223237 const RAW_SECRET : & str = "sk-RAWSECRETVALUE0123456789" ;
31233238
3124- let events = Arc :: new ( std:: sync:: Mutex :: new ( Vec :: new ( ) ) ) ;
3125- let subscriber = tracing_subscriber:: registry ( ) . with ( OcsfCaptureLayer ( Arc :: clone ( & events) ) ) ;
3126- let _guard = tracing:: subscriber:: set_default ( subscriber) ;
3127-
31283239 let ctx = L7EvalContext {
31293240 host : "api.example.test" . into ( ) ,
31303241 port : 443 ,
@@ -3171,23 +3282,26 @@ network_policies:
31713282 } ] ,
31723283 } ;
31733284
3174- emit_middleware_events ( & ctx, & req, & outcome) ;
3285+ // Build the events directly rather than routing through the global
3286+ // tracing pipeline: its callsite-interest cache is process-global, so a
3287+ // parallel test that emits OCSF with no subscriber installed can cache
3288+ // the callsite as disabled and make captured-event assertions flaky.
3289+ let events = middleware_events ( & ctx, & req, & outcome) ;
31753290
3176- let captured = events. lock ( ) . unwrap ( ) ;
31773291 // Per-invocation decisions are HTTP Activity (class 4002).
31783292 assert ! (
3179- captured . iter( ) . any( |e| e. class_uid( ) == 4002 ) ,
3293+ events . iter( ) . any( |e| e. class_uid( ) == 4002 ) ,
31803294 "expected an HTTP Activity event for the middleware invocation"
31813295 ) ;
31823296 // Findings are Detection Finding (class 2004) with the finding's severity.
3183- let finding_event = captured
3297+ let finding_event = events
31843298 . iter ( )
31853299 . find ( |e| e. class_uid ( ) == 2004 )
31863300 . expect ( "expected a Detection Finding event" ) ;
31873301 assert_eq ! ( finding_event. base( ) . severity, SeverityId :: Medium ) ;
31883302
31893303 // No raw payload material may appear in any emitted event.
3190- let serialized = serde_json:: to_string ( & * captured ) . expect ( "serialize events" ) ;
3304+ let serialized = serde_json:: to_string ( & events ) . expect ( "serialize events" ) ;
31913305 assert ! (
31923306 !serialized. contains( RAW_SECRET ) ,
31933307 "raw secret leaked into OCSF events: {serialized}"
@@ -3364,12 +3478,19 @@ network_policies:
33643478 . await
33653479 . unwrap ( ) ;
33663480
3367- let mut response = [ 0u8 ; 512 ] ;
3368- let n = tokio:: time:: timeout ( std:: time:: Duration :: from_secs ( 1 ) , app. read ( & mut response) )
3369- . await
3370- . expect ( "denial should reach client" )
3371- . unwrap ( ) ;
3372- let response = String :: from_utf8_lossy ( & response[ ..n] ) ;
3481+ // Accumulate until the reason marker arrives: the deny response can be
3482+ // delivered in more than one write, so a single read may return only the
3483+ // status line and flake the body assertion.
3484+ let mut response = Vec :: new ( ) ;
3485+ let mut buf = [ 0u8 ; 512 ] ;
3486+ while !String :: from_utf8_lossy ( & response) . contains ( "middleware_failed" ) {
3487+ match tokio:: time:: timeout ( std:: time:: Duration :: from_secs ( 1 ) , app. read ( & mut buf) ) . await {
3488+ Ok ( Ok ( 0 ) ) | Err ( _) => break , // clean EOF, or no more data before the deadline
3489+ Ok ( Ok ( n) ) => response. extend_from_slice ( & buf[ ..n] ) ,
3490+ Ok ( Err ( e) ) => panic ! ( "read from relay failed: {e}" ) ,
3491+ }
3492+ }
3493+ let response = String :: from_utf8_lossy ( & response) ;
33733494 assert ! ( response. contains( "403 Forbidden" ) ) ;
33743495 assert ! ( response. contains( "middleware_failed" ) ) ;
33753496
0 commit comments