Skip to content

Commit 9333943

Browse files
committed
fix(supervisor-middleware): ignore unresolved bindings in chain body limit
A chain entry whose binding did not resolve reported a zero body limit, which dragged the whole chain's buffer cap to zero and spuriously failed body-bearing requests over capacity even when a resolved middleware could have processed them. Exclude unresolved entries from the limit via a new DescribedChainEntry::is_resolved(); when no entry resolves, skip buffering and apply each entry's on_error directly. Also fix two parallel-test flakes found while validating the change: - Build middleware OCSF events into a Vec and assert on it directly instead of capturing through the global tracing pipeline, whose callsite-interest cache is process-global and raced under parallel runs. - Accumulate the websocket deny response until the reason marker arrives rather than assuming a single read returns the full body. Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
1 parent 71c0898 commit 9333943

2 files changed

Lines changed: 188 additions & 40 deletions

File tree

  • crates
    • openshell-supervisor-middleware/src
    • openshell-supervisor-network/src/l7

crates/openshell-supervisor-middleware/src/lib.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ impl DescribedChainEntry {
102102
pub fn on_error(&self) -> OnError {
103103
self.entry.on_error
104104
}
105+
106+
/// True when this entry resolved to a registered binding and will be
107+
/// evaluated. When false, the binding is absent from the current registry
108+
/// and the entry is handled entirely by its `on_error` policy, so it
109+
/// imposes no body-buffering limit on the chain.
110+
pub fn is_resolved(&self) -> bool {
111+
self.binding.is_some()
112+
}
105113
}
106114

107115
#[derive(Debug, Clone)]
@@ -1166,6 +1174,25 @@ mod tests {
11661174
}
11671175
}
11681176

1177+
#[tokio::test]
1178+
async fn describe_chain_marks_resolved_and_unresolved_entries() {
1179+
let unresolved = ChainEntry {
1180+
name: "missing".into(),
1181+
implementation: "third-party/missing".into(),
1182+
config: prost_types::Struct::default(),
1183+
on_error: OnError::FailOpen,
1184+
};
1185+
let described = ChainRunner::default()
1186+
.describe_chain(&[entry("redact", OnError::FailClosed), unresolved])
1187+
.await
1188+
.expect("describe chain");
1189+
// The built-in resolves and reports its real limit; the missing binding
1190+
// does not resolve and must not contribute a body limit.
1191+
assert!(described[0].is_resolved());
1192+
assert_eq!(described[0].max_body_bytes(), 256 * 1024);
1193+
assert!(!described[1].is_resolved());
1194+
}
1195+
11691196
#[tokio::test]
11701197
async fn descriptors_are_resolved_from_any_middleware_service() {
11711198
let runner = ChainRunner::new(Arc::new(ScriptedService {

crates/openshell-supervisor-network/src/l7/relay.rs

Lines changed: 161 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
781787
fn 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\nHost: api.example.test\r\nContent-Length: {}\r\nConnection: 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\nContent-Length: 0\r\nConnection: 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

Comments
 (0)