Skip to content

Commit 2f23c38

Browse files
committed
fix(middleware): buffer request bodies at the chain's largest stage limit
Buffering at the smallest stage limit forced the whole chain onto the unbuffered over-capacity path whenever the body exceeded any single stage, losing per-stage on_error behavior: a small fail-open stage could deny a request that a larger fail-closed stage was able to inspect. Buffer for the most capable stage instead, so stages whose own limit is smaller fail individually with request_body_over_capacity through their own on_error while the rest of the chain still runs. A body over every stage limit keeps the existing aggregate handling, which now matches the per-stage semantics by construction. Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
1 parent 104ab6c commit 2f23c38

6 files changed

Lines changed: 242 additions & 13 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,89 @@ mod tests {
13221322
);
13231323
}
13241324

1325+
#[tokio::test]
1326+
async fn undersized_stage_fails_open_while_later_stage_runs() {
1327+
// A body over one stage's limit must fail only that stage through its
1328+
// own `on_error`, not the whole chain: the 1 KiB fail-open guard is
1329+
// skipped while the 256 KiB fail-closed redactor still runs.
1330+
let external = Arc::new(ScriptedService {
1331+
binding_id: "example/content-guard".into(),
1332+
max_body_bytes: 4096,
1333+
result: allow_result(),
1334+
});
1335+
let registry = registry_with_external(external, external_registration(1024)).await;
1336+
let runner = ChainRunner::from_registry(registry);
1337+
let guard_entry = ChainEntry {
1338+
name: "guard".into(),
1339+
implementation: "example/content-guard".into(),
1340+
order: 0,
1341+
config: prost_types::Struct::default(),
1342+
on_error: OnError::FailOpen,
1343+
};
1344+
let mut redact_entry = entry("redact", OnError::FailClosed);
1345+
redact_entry.order = 10;
1346+
let entries = [guard_entry, redact_entry];
1347+
1348+
let body = format!("{}password=\"top-secret\"", "x".repeat(1500));
1349+
let outcome = runner
1350+
.evaluate(&entries, input(&body))
1351+
.await
1352+
.expect("evaluate mixed-limit chain");
1353+
1354+
assert!(outcome.allowed);
1355+
assert_eq!(outcome.applied.len(), 2);
1356+
assert!(
1357+
outcome.applied[0].failed,
1358+
"undersized guard must be skipped"
1359+
);
1360+
assert_eq!(outcome.applied[0].decision, Decision::Allow);
1361+
assert!(!outcome.applied[1].failed);
1362+
assert!(outcome.applied[1].transformed);
1363+
let body = String::from_utf8(outcome.body).expect("utf8");
1364+
assert!(body.contains("[REDACTED]"));
1365+
assert!(!body.contains("top-secret"));
1366+
}
1367+
1368+
#[tokio::test]
1369+
async fn transformed_body_still_over_later_stage_capacity_honors_on_error() {
1370+
// Per-stage capacity applies to the current body: the redactor's
1371+
// replacement is still over the 1 KiB guard limit, so the fail-closed
1372+
// guard denies through its own `on_error` after the redactor ran.
1373+
let external = Arc::new(ScriptedService {
1374+
binding_id: "example/content-guard".into(),
1375+
max_body_bytes: 4096,
1376+
result: allow_result(),
1377+
});
1378+
let registry = registry_with_external(external, external_registration(1024)).await;
1379+
let runner = ChainRunner::from_registry(registry);
1380+
let guard_entry = ChainEntry {
1381+
name: "guard".into(),
1382+
implementation: "example/content-guard".into(),
1383+
order: 10,
1384+
config: prost_types::Struct::default(),
1385+
on_error: OnError::FailClosed,
1386+
};
1387+
let entries = [entry("redact", OnError::FailClosed), guard_entry];
1388+
1389+
let body = format!("{}password=\"top-secret\"", "x".repeat(1500));
1390+
let outcome = runner
1391+
.evaluate(&entries, input(&body))
1392+
.await
1393+
.expect("evaluate mixed-limit chain");
1394+
1395+
assert!(!outcome.allowed);
1396+
assert_eq!(
1397+
outcome.reason,
1398+
"middleware_failed: request_body_over_capacity"
1399+
);
1400+
assert_eq!(outcome.applied.len(), 2);
1401+
assert!(
1402+
outcome.applied[0].transformed,
1403+
"redactor ran before the deny"
1404+
);
1405+
assert!(outcome.applied[1].failed);
1406+
}
1407+
13251408
#[test]
13261409
fn external_manifest_rejects_operator_limit_above_capability() {
13271410
let registration = external_registration(4097);

crates/openshell-supervisor-network/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ webpki-roots = { workspace = true }
5252
[dev-dependencies]
5353
openshell-core = { path = "../openshell-core", features = ["test-helpers"] }
5454
tempfile = "3"
55+
tonic = { workspace = true }
5556
temp-env = "0.3"
5657
tokio-tungstenite = { workspace = true }
5758
futures = { workspace = true }

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

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@ pub enum MiddlewareApplyResult {
1919
Denied(String),
2020
}
2121

22-
/// Smallest body-buffering limit across the entries that actually resolved to a
23-
/// registered binding. Unresolved entries (`is_resolved() == false`) report a
24-
/// zero limit and are excluded here: they are handled by their `on_error` policy
25-
/// in `evaluate_described` without inspecting the body, so letting a zero drag
26-
/// the chain limit to zero would spuriously fail the whole chain over capacity.
22+
/// Largest body-buffering limit across the entries that actually resolved to a
23+
/// registered binding. Buffering for the most capable stage lets every stage
24+
/// that can handle the body run; stages whose own limit is smaller are failed
25+
/// individually with `request_body_over_capacity` through their `on_error`
26+
/// policy in `evaluate_described`, instead of one undersized stage forcing the
27+
/// whole chain onto the unbuffered path. Unresolved entries
28+
/// (`is_resolved() == false`) report a zero limit and are excluded here: they
29+
/// are handled by their `on_error` policy without inspecting the body.
2730
/// Returns `None` when no entry resolved, so the caller can skip buffering.
2831
pub(super) fn middleware_chain_body_limit(
2932
chain: &[openshell_supervisor_middleware::DescribedChainEntry],
@@ -32,7 +35,7 @@ pub(super) fn middleware_chain_body_limit(
3235
.iter()
3336
.filter(|entry| entry.is_resolved())
3437
.map(openshell_supervisor_middleware::DescribedChainEntry::max_body_bytes)
35-
.min()
38+
.max()
3639
}
3740

3841
pub async fn apply_middleware_chain<C: AsyncRead + AsyncWrite + Unpin + Send>(
@@ -147,10 +150,11 @@ pub(super) fn raw_query_from_request_headers(headers: &[u8]) -> Result<String> {
147150
.map_or_else(String::new, |(_, query)| query.to_string()))
148151
}
149152

150-
/// Apply the chain's `on_error` policy when the request body cannot be buffered
151-
/// for inspection because it exceeds the size cap. The RFC treats an unbufferable
152-
/// body as an `on_error` event: it is denied unless every attached middleware is
153-
/// `fail_open`, and passing it through is only safe when no bytes were consumed.
153+
/// Apply the chain's `on_error` policy when the request body exceeds every
154+
/// stage's buffering limit. No stage can inspect such a body, so each stage
155+
/// would individually fail with `request_body_over_capacity`; the aggregate is
156+
/// a deny unless every attached middleware is `fail_open`, and passing the
157+
/// body through is only safe when no bytes were consumed.
154158
pub(super) fn resolve_unbuffered_body(
155159
ctx: &L7EvalContext,
156160
req: crate::l7::provider::L7Request,

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

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2875,6 +2875,146 @@ network_policies:
28752875
assert_eq!(middleware_chain_body_limit(&none), None);
28762876
}
28772877

2878+
/// A middleware service advertising two bindings with different body
2879+
/// limits, for exercising mixed-limit chain buffering at the relay level.
2880+
/// The redactor binding replaces the body; the guard binding allows as-is.
2881+
struct TwoLimitService;
2882+
2883+
#[tonic::async_trait]
2884+
impl openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware
2885+
for TwoLimitService
2886+
{
2887+
async fn describe(
2888+
&self,
2889+
_request: tonic::Request<()>,
2890+
) -> std::result::Result<
2891+
tonic::Response<openshell_core::proto::MiddlewareManifest>,
2892+
tonic::Status,
2893+
> {
2894+
use openshell_core::proto::{
2895+
MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation,
2896+
SupervisorMiddlewarePhase,
2897+
};
2898+
let binding = |id: &str, max_body_bytes: u64| MiddlewareBinding {
2899+
id: id.into(),
2900+
operation: SupervisorMiddlewareOperation::HttpRequest as i32,
2901+
phase: SupervisorMiddlewarePhase::PreCredentials as i32,
2902+
max_body_bytes,
2903+
};
2904+
Ok(tonic::Response::new(MiddlewareManifest {
2905+
name: "test/two-limits".into(),
2906+
service_version: "test".into(),
2907+
bindings: vec![binding("test/redactor", 8192), binding("test/guard", 16)],
2908+
}))
2909+
}
2910+
2911+
async fn validate_config(
2912+
&self,
2913+
_request: tonic::Request<openshell_core::proto::ValidateConfigRequest>,
2914+
) -> std::result::Result<
2915+
tonic::Response<openshell_core::proto::ValidateConfigResponse>,
2916+
tonic::Status,
2917+
> {
2918+
Ok(tonic::Response::new(
2919+
openshell_core::proto::ValidateConfigResponse {
2920+
valid: true,
2921+
reason: String::new(),
2922+
},
2923+
))
2924+
}
2925+
2926+
async fn evaluate_http_request(
2927+
&self,
2928+
request: tonic::Request<openshell_core::proto::HttpRequestEvaluation>,
2929+
) -> std::result::Result<
2930+
tonic::Response<openshell_core::proto::HttpRequestResult>,
2931+
tonic::Status,
2932+
> {
2933+
let evaluation = request.into_inner();
2934+
let mut result = openshell_core::proto::HttpRequestResult {
2935+
decision: openshell_core::proto::Decision::Allow as i32,
2936+
..Default::default()
2937+
};
2938+
if evaluation.binding_id == "test/redactor" {
2939+
result.body = b"[SCRUBBED BY TEST REDACTOR]".to_vec();
2940+
result.has_body = true;
2941+
}
2942+
Ok(tonic::Response::new(result))
2943+
}
2944+
}
2945+
2946+
#[tokio::test]
2947+
async fn body_over_smallest_stage_limit_is_buffered_and_evaluated() {
2948+
use openshell_supervisor_middleware::{ChainEntry, ChainRunner, OnError};
2949+
2950+
// A 64-byte body exceeds the 16-byte guard limit but fits the 8 KiB
2951+
// redactor. The chain must buffer for its largest stage so the
2952+
// redactor runs and replaces the body, while the undersized fail-open
2953+
// guard is skipped through its own on_error, instead of the whole
2954+
// chain taking the unbuffered over-capacity path.
2955+
let (_config, tunnel_engine, ctx) =
2956+
middleware_relay_context("openshell/secrets", "fail_closed");
2957+
let runner = ChainRunner::new(Arc::new(TwoLimitService));
2958+
let chain = vec![
2959+
ChainEntry {
2960+
name: "redact".into(),
2961+
implementation: "test/redactor".into(),
2962+
order: 0,
2963+
config: prost_types::Struct::default(),
2964+
on_error: OnError::FailClosed,
2965+
},
2966+
ChainEntry {
2967+
name: "guard".into(),
2968+
implementation: "test/guard".into(),
2969+
order: 10,
2970+
config: prost_types::Struct::default(),
2971+
on_error: OnError::FailOpen,
2972+
},
2973+
];
2974+
let described = runner.describe_chain(&chain).await.expect("describe chain");
2975+
assert_eq!(middleware_chain_body_limit(&described), Some(8192));
2976+
2977+
let body = [b'a'; 64];
2978+
let raw_header = format!(
2979+
"POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\n\r\n",
2980+
body.len()
2981+
);
2982+
let req = crate::l7::provider::L7Request {
2983+
action: "POST".into(),
2984+
target: "/v1/messages".into(),
2985+
query_params: std::collections::HashMap::new(),
2986+
raw_header: raw_header.into_bytes(),
2987+
body_length: crate::l7::provider::BodyLength::ContentLength(body.len() as u64),
2988+
};
2989+
let (mut app, mut relay_client) = tokio::io::duplex(8192);
2990+
app.write_all(&body).await.unwrap();
2991+
2992+
let result = crate::l7::middleware::apply_middleware_chain_for_scheme(
2993+
req,
2994+
&mut relay_client,
2995+
&ctx,
2996+
"https",
2997+
chain,
2998+
&runner,
2999+
tunnel_engine.generation_guard(),
3000+
)
3001+
.await
3002+
.expect("apply middleware chain");
3003+
3004+
match result {
3005+
MiddlewareApplyResult::Allowed(rebuilt) => {
3006+
let raw = String::from_utf8(rebuilt.raw_header).expect("utf8 request");
3007+
assert!(
3008+
raw.ends_with("[SCRUBBED BY TEST REDACTOR]"),
3009+
"redactor must replace the body: {raw}"
3010+
);
3011+
}
3012+
MiddlewareApplyResult::Denied(reason) => {
3013+
panic!("body within the largest stage limit must not fail the chain: {reason}")
3014+
}
3015+
}
3016+
}
3017+
28783018
#[tokio::test]
28793019
async fn all_unresolved_fail_open_forwards_body_unbuffered() {
28803020
// A chain whose only entry is an unregistered binding has no resolvable

docs/extensibility/supervisor-middleware.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ For each inspected HTTP request, the supervisor:
1717

1818
1. Evaluates network and L7 policy.
1919
2. Selects middleware whose host selectors match the admitted destination.
20-
3. Buffers the request body using the smallest body limit in the selected chain.
20+
3. Buffers the request body using the largest body limit in the selected chain.
2121
4. Runs matching middleware by ascending `order`, using the policy-local name to break ties.
2222
5. Applies allowed transformations, injects provider credentials, and forwards the request.
2323

@@ -99,10 +99,10 @@ Every middleware binding declares the largest request or replacement body it sup
9999

100100
- Built-in middleware uses its OpenShell-defined limit.
101101
- Each operator-run registration sets `max_body_bytes` no higher than the service capability.
102-
- A selected chain buffers using its smallest stage limit.
102+
- A selected chain buffers using its largest stage limit, so every stage that can process the body receives it.
103103
- The same per-stage limit applies to request bodies and replacement bodies.
104104

105-
The gateway rejects a registration whose operator limit exceeds the service capability instead of silently clamping it. At request time, exceeding a selected stage's limit is a middleware failure and follows that config's `on_error` behavior.
105+
The gateway rejects a registration whose operator limit exceeds the service capability instead of silently clamping it. At request time, exceeding a selected stage's limit is a middleware failure for that stage alone and follows that config's `on_error` behavior; other stages in the chain still run against their own limits. A body larger than every stage limit cannot be inspected at all and is denied unless every selected stage is `fail_open`.
106106

107107
## Add Request Headers
108108

0 commit comments

Comments
 (0)