Skip to content

Commit 59725ee

Browse files
committed
fix(supervisor-middleware): harden policy enforcement
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
1 parent 9333943 commit 59725ee

5 files changed

Lines changed: 228 additions & 16 deletions

File tree

crates/openshell-sandbox/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1494,7 +1494,12 @@ async fn load_policy(
14941494
let opa_engine = Some(Arc::new(engine));
14951495

14961496
let policy = SandboxPolicy::try_from(proto_policy.clone())?;
1497-
return Ok((policy, opa_engine, Some(proto_policy), middleware_install_pending));
1497+
return Ok((
1498+
policy,
1499+
opa_engine,
1500+
Some(proto_policy),
1501+
middleware_install_pending,
1502+
));
14981503
}
14991504

15001505
// No policy source available

crates/openshell-server/src/grpc/policy.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3135,6 +3135,18 @@ fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String {
31353135
hasher.update(key.as_bytes());
31363136
hasher.update(value.encode_to_vec());
31373137
}
3138+
if !policy.network_middlewares.is_empty() {
3139+
hasher.update(b"network_middlewares");
3140+
for middleware in &policy.network_middlewares {
3141+
let encoded = middleware.encode_to_vec();
3142+
hasher.update(
3143+
u64::try_from(encoded.len())
3144+
.expect("protobuf payload length fits in u64")
3145+
.to_le_bytes(),
3146+
);
3147+
hasher.update(encoded);
3148+
}
3149+
}
31383150
hex::encode(hasher.finalize())
31393151
}
31403152

@@ -9315,6 +9327,26 @@ mod tests {
93159327
assert_ne!(rev_a, rev_b);
93169328
}
93179329

9330+
#[test]
9331+
fn policy_hash_changes_when_network_middlewares_change() {
9332+
let policy_a = ProtoSandboxPolicy::default();
9333+
let policy_b = ProtoSandboxPolicy {
9334+
network_middlewares: vec![openshell_core::proto::NetworkMiddlewareConfig {
9335+
name: "redact-secrets".into(),
9336+
middleware: "openshell/secrets".into(),
9337+
on_error: "fail_closed".into(),
9338+
..Default::default()
9339+
}],
9340+
..Default::default()
9341+
};
9342+
9343+
assert_ne!(
9344+
deterministic_policy_hash(&policy_a),
9345+
deterministic_policy_hash(&policy_b),
9346+
"middleware-only policy changes must produce a new policy hash"
9347+
);
9348+
}
9349+
93189350
#[test]
93199351
fn config_revision_changes_when_policy_source_changes() {
93209352
let policy = ProtoSandboxPolicy::default();

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

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,37 @@ impl ChainRunner {
712712
}
713713
};
714714

715+
if decision == Decision::Deny {
716+
for finding in result.findings {
717+
findings.push(NamespacedFinding {
718+
middleware: entry.entry.name.clone(),
719+
finding,
720+
});
721+
}
722+
if !result.metadata.is_empty() {
723+
metadata.insert(
724+
entry.entry.name.clone(),
725+
result.metadata.into_iter().collect(),
726+
);
727+
}
728+
applied.push(MiddlewareInvocation {
729+
name: entry.entry.name.clone(),
730+
implementation: entry.entry.implementation.clone(),
731+
decision,
732+
transformed: false,
733+
failed: false,
734+
});
735+
return Ok(ChainOutcome {
736+
allowed: false,
737+
reason: safe_reason(&result.reason),
738+
body,
739+
added_headers,
740+
findings,
741+
metadata,
742+
applied,
743+
});
744+
}
745+
715746
if result.has_body && result.body.len() > entry.max_body_bytes {
716747
match apply_on_error(entry, "response_body_over_capacity", &mut applied) {
717748
OnErrorAction::FailOpen => continue,
@@ -774,17 +805,6 @@ impl ChainRunner {
774805
transformed,
775806
failed: false,
776807
});
777-
if decision == Decision::Deny {
778-
return Ok(ChainOutcome {
779-
allowed: false,
780-
reason: safe_reason(&result.reason),
781-
body,
782-
added_headers,
783-
findings,
784-
metadata,
785-
applied,
786-
});
787-
}
788808
}
789809

790810
Ok(ChainOutcome {
@@ -1399,6 +1419,62 @@ mod tests {
13991419
assert!(!outcome.applied[0].failed);
14001420
}
14011421

1422+
#[tokio::test]
1423+
async fn deny_decision_ignores_unsafe_mutations_under_fail_open() {
1424+
let runner = ChainRunner::new(Arc::new(scripted_service(
1425+
openshell_core::proto::HttpRequestResult {
1426+
decision: Decision::Deny as i32,
1427+
reason: "blocked_by_policy".into(),
1428+
add_headers: std::iter::once((
1429+
"x-openshell-middleware-inject".to_string(),
1430+
"ok\r\nHost: evil".to_string(),
1431+
))
1432+
.collect(),
1433+
..allow_result()
1434+
},
1435+
)));
1436+
1437+
let outcome = runner
1438+
.evaluate(&[entry("guard", OnError::FailOpen)], input("hello"))
1439+
.await
1440+
.expect("evaluate");
1441+
1442+
assert!(!outcome.allowed);
1443+
assert_eq!(outcome.reason, "blocked_by_policy");
1444+
assert!(outcome.added_headers.is_empty());
1445+
assert_eq!(outcome.applied.len(), 1);
1446+
assert_eq!(outcome.applied[0].decision, Decision::Deny);
1447+
assert!(!outcome.applied[0].failed);
1448+
}
1449+
1450+
#[tokio::test]
1451+
async fn deny_decision_ignores_oversized_replacement_under_fail_open() {
1452+
let runner = ChainRunner::new(Arc::new(ScriptedService {
1453+
binding_id: BUILTIN_SECRETS.into(),
1454+
max_body_bytes: 4,
1455+
result: openshell_core::proto::HttpRequestResult {
1456+
decision: Decision::Deny as i32,
1457+
reason: "blocked_by_policy".into(),
1458+
body: b"too large".to_vec(),
1459+
has_body: true,
1460+
..allow_result()
1461+
},
1462+
}));
1463+
1464+
let outcome = runner
1465+
.evaluate(&[entry("guard", OnError::FailOpen)], input("safe"))
1466+
.await
1467+
.expect("evaluate");
1468+
1469+
assert!(!outcome.allowed);
1470+
assert_eq!(outcome.reason, "blocked_by_policy");
1471+
assert_eq!(outcome.body, b"safe");
1472+
assert_eq!(outcome.applied.len(), 1);
1473+
assert_eq!(outcome.applied[0].decision, Decision::Deny);
1474+
assert!(!outcome.applied[0].transformed);
1475+
assert!(!outcome.applied[0].failed);
1476+
}
1477+
14021478
#[tokio::test]
14031479
async fn metadata_and_findings_are_namespaced_per_config() {
14041480
let runner = ChainRunner::new(Arc::new(scripted_service(

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

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -824,7 +824,14 @@ pub(crate) async fn apply_middleware_chain_for_scheme<C: AsyncRead + AsyncWrite
824824
// body. Apply each entry's `on_error` policy without buffering (an
825825
// unresolved binding is handled before the body is read) and forward
826826
// the original request unchanged if the chain allows.
827-
let input = middleware_request_input(scheme, &req, ctx, BTreeMap::new(), String::new(), Vec::new());
827+
let input = middleware_request_input(
828+
scheme,
829+
&req,
830+
ctx,
831+
BTreeMap::new(),
832+
String::new(),
833+
Vec::new(),
834+
);
828835
let outcome = runner.evaluate_described(&chain, input).await?;
829836
emit_middleware_events(ctx, &req, &outcome);
830837
return Ok(if outcome.allowed {
@@ -2823,6 +2830,72 @@ network_policies:
28232830
.unwrap();
28242831
}
28252832

2833+
#[tokio::test]
2834+
async fn l7_rest_middleware_acknowledges_expect_continue_before_reading_body() {
2835+
let (config, tunnel_engine, ctx) =
2836+
middleware_relay_context("openshell/secrets", "fail_closed");
2837+
let (mut app, mut relay_client) = tokio::io::duplex(8192);
2838+
let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192);
2839+
let relay = tokio::spawn(async move {
2840+
relay_with_inspection(
2841+
&config,
2842+
tunnel_engine,
2843+
&mut relay_client,
2844+
&mut relay_upstream,
2845+
&ctx,
2846+
)
2847+
.await
2848+
});
2849+
2850+
let body = br#"{"api_key":"sk-1234567890abcdef"}"#;
2851+
let headers = format!(
2852+
"POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nExpect: 100-continue\r\nConnection: close\r\n\r\n",
2853+
body.len()
2854+
);
2855+
app.write_all(headers.as_bytes()).await.unwrap();
2856+
2857+
let mut interim = [0u8; 64];
2858+
let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut interim))
2859+
.await
2860+
.expect("middleware buffering should acknowledge Expect before reading the body")
2861+
.unwrap();
2862+
assert_eq!(&interim[..n], b"HTTP/1.1 100 Continue\r\n\r\n");
2863+
2864+
app.write_all(body).await.unwrap();
2865+
2866+
let mut upstream_request = [0u8; 1024];
2867+
let n = tokio::time::timeout(
2868+
std::time::Duration::from_secs(1),
2869+
upstream.read(&mut upstream_request),
2870+
)
2871+
.await
2872+
.expect("request should reach upstream after the body is released")
2873+
.unwrap();
2874+
let upstream_request = String::from_utf8_lossy(&upstream_request[..n]);
2875+
assert!(upstream_request.contains(r#""api_key":"[REDACTED]""#));
2876+
assert!(!upstream_request.contains("Expect: 100-continue"));
2877+
2878+
upstream
2879+
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
2880+
.await
2881+
.unwrap();
2882+
let mut client_response = [0u8; 512];
2883+
let n = tokio::time::timeout(
2884+
std::time::Duration::from_secs(1),
2885+
app.read(&mut client_response),
2886+
)
2887+
.await
2888+
.expect("response should reach client")
2889+
.unwrap();
2890+
assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content"));
2891+
drop(app);
2892+
tokio::time::timeout(std::time::Duration::from_secs(1), relay)
2893+
.await
2894+
.expect("relay should finish")
2895+
.unwrap()
2896+
.unwrap();
2897+
}
2898+
28262899
#[tokio::test]
28272900
async fn l7_rest_middleware_fail_closed_does_not_reach_upstream() {
28282901
let (config, tunnel_engine, ctx) =
@@ -3484,7 +3557,8 @@ network_policies:
34843557
let mut response = Vec::new();
34853558
let mut buf = [0u8; 512];
34863559
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 {
3560+
match tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut buf)).await
3561+
{
34883562
Ok(Ok(0)) | Err(_) => break, // clean EOF, or no more data before the deadline
34893563
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
34903564
Ok(Err(e)) => panic!("read from relay failed: {e}"),

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -828,7 +828,7 @@ pub(crate) enum BufferResult {
828828
OverCapacity { recoverable: bool },
829829
}
830830

831-
pub(crate) async fn buffer_request_body_for_middleware<C: AsyncRead + Unpin>(
831+
pub(crate) async fn buffer_request_body_for_middleware<C: AsyncRead + AsyncWrite + Unpin>(
832832
req: &L7Request,
833833
client: &mut C,
834834
generation_guard: Option<&PolicyGenerationGuard>,
@@ -839,7 +839,7 @@ pub(crate) async fn buffer_request_body_for_middleware<C: AsyncRead + Unpin>(
839839
.windows(4)
840840
.position(|w| w == b"\r\n\r\n")
841841
.map_or(req.raw_header.len(), |p| p + 4);
842-
let headers = req.raw_header[..header_end].to_vec();
842+
let mut headers = req.raw_header[..header_end].to_vec();
843843
let already_read = &req.raw_header[header_end..];
844844
match req.body_length {
845845
BodyLength::None => Ok(BufferResult::Buffered(BufferedRequestBody {
@@ -860,6 +860,9 @@ pub(crate) async fn buffer_request_body_for_middleware<C: AsyncRead + Unpin>(
860860
let mut body = Vec::with_capacity(len);
861861
body.extend_from_slice(&already_read[..initial_len]);
862862
let mut remaining = len.saturating_sub(initial_len);
863+
if remaining > 0 && already_read.is_empty() {
864+
acknowledge_expect_continue(client, &mut headers).await?;
865+
}
863866
let mut buf = [0u8; RELAY_BUF_SIZE];
864867
while remaining > 0 {
865868
let to_read = remaining.min(buf.len());
@@ -887,6 +890,9 @@ pub(crate) async fn buffer_request_body_for_middleware<C: AsyncRead + Unpin>(
887890
// we have already consumed wire bytes from the client stream and
888891
// cannot re-enter the normal raw relay path without a separate
889892
// splice-through buffer.
893+
if already_read.is_empty() {
894+
acknowledge_expect_continue(client, &mut headers).await?;
895+
}
890896
Ok(
891897
collect_chunked_body(client, already_read, generation_guard, Some(max_body_bytes))
892898
.await
@@ -898,6 +904,25 @@ pub(crate) async fn buffer_request_body_for_middleware<C: AsyncRead + Unpin>(
898904
}
899905
}
900906

907+
async fn acknowledge_expect_continue<C: AsyncWrite + Unpin>(
908+
client: &mut C,
909+
headers: &mut Vec<u8>,
910+
) -> Result<()> {
911+
let header_str =
912+
std::str::from_utf8(headers).map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?;
913+
if !has_expect_continue(header_str) {
914+
return Ok(());
915+
}
916+
917+
client
918+
.write_all(b"HTTP/1.1 100 Continue\r\n\r\n")
919+
.await
920+
.into_diagnostic()?;
921+
client.flush().await.into_diagnostic()?;
922+
*headers = strip_header(headers, "expect")?;
923+
Ok(())
924+
}
925+
901926
pub(crate) fn rebuild_request_with_buffered_body(
902927
req: &L7Request,
903928
headers: &[u8],

0 commit comments

Comments
 (0)