Skip to content

Commit 1b1cbd3

Browse files
committed
fix(policy): align wildcard and sigv4 body validation
Signed-off-by: Russell Bryant <rbryant@redhat.com>
1 parent c49d8c6 commit 1b1cbd3

2 files changed

Lines changed: 123 additions & 6 deletions

File tree

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

crates/openshell-policy/src/lib.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -935,7 +935,10 @@ pub fn validate_sandbox_policy(
935935
}
936936

937937
fn host_wildcard_shape_invalid(host: &str) -> bool {
938-
if !host.contains('*') || host == "*" || host == "**" {
938+
if host == "*" || host == "**" {
939+
return true;
940+
}
941+
if !host.contains('*') {
939942
return false;
940943
}
941944
let labels: Vec<&str> = host.split('.').collect();
@@ -1447,6 +1450,33 @@ network_policies:
14471450
);
14481451
}
14491452

1453+
#[test]
1454+
fn validate_rejects_all_host_star_wildcards() {
1455+
for host in ["*", "**"] {
1456+
let mut policy = restrictive_default_policy();
1457+
policy.network_policies.insert(
1458+
"bad".into(),
1459+
NetworkPolicyRule {
1460+
name: "bad-rule".into(),
1461+
endpoints: vec![NetworkEndpoint {
1462+
host: host.into(),
1463+
port: 443,
1464+
..Default::default()
1465+
}],
1466+
..Default::default()
1467+
},
1468+
);
1469+
1470+
let violations = validate_sandbox_policy(&policy).unwrap_err();
1471+
assert!(
1472+
violations
1473+
.iter()
1474+
.any(|v| matches!(v, PolicyViolation::InvalidHostWildcard { .. })),
1475+
"expected bare host wildcard {host:?} to be rejected, got {violations:?}"
1476+
);
1477+
}
1478+
}
1479+
14501480
#[test]
14511481
fn validate_accepts_subdomain_wildcard() {
14521482
let mut policy = restrictive_default_policy();

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

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -560,11 +560,17 @@ where
560560
// route chunked requests to the streaming path, but
561561
// guard here as defense-in-depth.
562562
let body_length = parse_body_length(header_str)?;
563-
if matches!(body_length, BodyLength::Chunked) {
564-
return Err(miette!(
565-
"SigV4 body signing requires Content-Length; \
566-
chunked transfer encoding is not supported in this mode"
567-
));
563+
match body_length {
564+
BodyLength::ContentLength(_) => {}
565+
BodyLength::Chunked => {
566+
return Err(miette!(
567+
"SigV4 body signing requires Content-Length; \
568+
chunked transfer encoding is not supported in this mode"
569+
));
570+
}
571+
BodyLength::None => {
572+
return Err(miette!("SigV4 body signing requires Content-Length"));
573+
}
568574
}
569575
// NOTE(defense-in-depth): Build the full request from
570576
// rewritten headers + body. `rewrite_result.rewritten`
@@ -5262,6 +5268,87 @@ mod tests {
52625268
assert!(!forwarded.contains("openshell:resolve:env:"));
52635269
}
52645270

5271+
#[tokio::test]
5272+
async fn relay_sigv4_body_signing_rejects_missing_content_length() {
5273+
let (_, resolver) = SecretResolver::from_provider_env(
5274+
[
5275+
("AWS_ACCESS_KEY_ID".to_string(), "AKIATESTKEY".to_string()),
5276+
(
5277+
"AWS_SECRET_ACCESS_KEY".to_string(),
5278+
"test-secret-key".to_string(),
5279+
),
5280+
]
5281+
.into_iter()
5282+
.collect(),
5283+
);
5284+
let resolver = resolver.expect("resolver");
5285+
5286+
let raw_header =
5287+
b"PUT /bucket/key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\n\r\n".to_vec();
5288+
let req = L7Request {
5289+
action: "PUT".to_string(),
5290+
target: "/bucket/key".to_string(),
5291+
query_params: HashMap::new(),
5292+
raw_header,
5293+
body_length: BodyLength::None,
5294+
};
5295+
let (mut proxy_to_upstream, mut upstream_side) = tokio::io::duplex(8192);
5296+
let (mut _app_side, mut proxy_to_client) = tokio::io::duplex(8192);
5297+
5298+
let upstream_task = tokio::spawn(async move {
5299+
let mut buf = vec![0u8; 8192];
5300+
let mut total = 0usize;
5301+
loop {
5302+
let n = upstream_side.read(&mut buf[total..]).await.unwrap();
5303+
if n == 0 {
5304+
break;
5305+
}
5306+
total += n;
5307+
if buf[..total].windows(4).any(|w| w == b"\r\n\r\n") {
5308+
upstream_side
5309+
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
5310+
.await
5311+
.unwrap();
5312+
upstream_side.flush().await.unwrap();
5313+
break;
5314+
}
5315+
}
5316+
String::from_utf8_lossy(&buf[..total]).to_string()
5317+
});
5318+
5319+
let relay_result = tokio::time::timeout(
5320+
std::time::Duration::from_secs(5),
5321+
relay_http_request_with_options_guarded(
5322+
&req,
5323+
&mut proxy_to_client,
5324+
&mut proxy_to_upstream,
5325+
RelayRequestOptions {
5326+
resolver: Some(&resolver),
5327+
credential_signing: crate::l7::CredentialSigning::SigV4Body,
5328+
signing_service: "s3",
5329+
signing_region: "us-east-1",
5330+
host: "s3.us-east-1.amazonaws.com",
5331+
port: 443,
5332+
..Default::default()
5333+
},
5334+
),
5335+
)
5336+
.await
5337+
.expect("relay must not deadlock");
5338+
drop(proxy_to_upstream);
5339+
let forwarded = upstream_task.await.expect("upstream task should complete");
5340+
5341+
let err = relay_result.expect_err("missing Content-Length should fail closed");
5342+
assert!(
5343+
err.to_string().contains("requires Content-Length"),
5344+
"unexpected error: {err}"
5345+
);
5346+
assert!(
5347+
forwarded.is_empty(),
5348+
"request missing Content-Length must not reach upstream: {forwarded}"
5349+
);
5350+
}
5351+
52655352
#[tokio::test]
52665353
async fn relay_injects_url_path_credential_telegram_style() {
52675354
let (child_env, resolver) = SecretResolver::from_provider_env(

0 commit comments

Comments
 (0)