Skip to content
Open
96 changes: 90 additions & 6 deletions crates/openshell-supervisor-network/src/l7/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,11 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec<String>, Vec<
};

for (i, ep) in endpoints.iter().enumerate() {
let loc = format!("{name}.endpoints[{i}]");
if !ep.is_object() {
errors.push(format!("{loc}: endpoint entry must be an object"));
continue;
}
let protocol = ep.get("protocol").and_then(|v| v.as_str()).unwrap_or("");
let l7_protocol = L7Protocol::parse(protocol);
let jsonrpc_family = l7_protocol.is_some_and(L7Protocol::is_jsonrpc_family);
Expand All @@ -986,9 +991,13 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec<String>, Vec<
.into_iter()
.collect()
},
|arr| arr.iter().filter_map(serde_json::Value::as_u64).collect(),
|arr| {
arr.iter()
.filter_map(serde_json::Value::as_u64)
.filter(|p| *p > 0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gator-agent

Warning: This filters only a temporary vector whose sole consumer already checks any(|port| *port > 0). Consequently, [0] was already rejected, while [0, 443] still passes and the zero remains in the JSON supplied to OPA. Please either reject any zero-valued ports member or remove zeros during endpoint normalization/proto serialization, then cover all-zero and mixed arrays with regression tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will get to these later today thanks for raising this issue.

.collect()
},
);
let loc = format!("{name}.endpoints[{i}]");

if protocol == "mcp" {
if host.trim().is_empty() {
Expand Down Expand Up @@ -1557,7 +1566,13 @@ pub fn expand_access_presets(data: &mut serde_json::Value) -> Vec<String> {
&& !has_rules
&& mcp_allow_all_known_mcp_methods
{
ep.as_object_mut().unwrap().insert(
let Some(obj) = ep.as_object_mut() else {
warnings.push(format!(
"{name}.endpoints[{i}]: endpoint entry is not an object; skipping access preset expansion"
));
continue;
};
obj.insert(
"rules".to_string(),
serde_json::Value::Array(vec![jsonrpc_rule_json("*")]),
);
Expand All @@ -1582,9 +1597,13 @@ pub fn expand_access_presets(data: &mut serde_json::Value) -> Vec<String> {
continue;
};

ep.as_object_mut()
.unwrap()
.insert("rules".to_string(), serde_json::Value::Array(rules));
if let Some(obj) = ep.as_object_mut() {
obj.insert("rules".to_string(), serde_json::Value::Array(rules));
} else {
warnings.push(format!(
"{name}.endpoints[{i}]: endpoint entry is not an object; skipping access preset expansion"
));
}
}
}

Expand Down Expand Up @@ -1629,6 +1648,34 @@ fn graphql_rule_json(operation_type: &str) -> serde_json::Value {
mod tests {
use super::*;

#[test]
fn validate_l7_policies_rejects_non_object_endpoint() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gator-agent

Suggestion: This test verifies validation, but not expand_access_presets, where the unwraps were removed. Please add a direct expansion test containing a non-object endpoint and a valid preset endpoint, asserting the invalid entry remains unchanged and the valid preset expands. It would also be stronger to assert indexed errors for both malformed entries.

let data = serde_json::json!({
"network_policies": {
"test": {
"endpoints": [
"not-an-object",
{"host": "api.example.com", "port": 443, "protocol": "rest"},
42,
],
"binaries": []
}
}
});
let (errors, _warnings) = validate_l7_policies(&data);
assert!(
errors
.iter()
.any(|e| e.contains("endpoint entry must be an object")),
"expected non-object endpoint error: {errors:?}"
);
// The valid object endpoint should not produce an error.
assert!(
!errors.iter().any(|e| e.contains("api.example.com")),
"valid endpoint should not be blamed: {errors:?}"
);
}

#[test]
fn parse_l7_config_rest_enforce() {
let val = regorus::Value::from_json_str(
Expand Down Expand Up @@ -2810,6 +2857,43 @@ mod tests {
);
}

#[test]
fn expand_access_presets_skips_non_object_endpoint_and_expands_valid() {
let mut data = serde_json::json!({
"network_policies": {
"test": {
"endpoints": [
"not-an-object",
{
"host": "api.example.com",
"port": 80,
"protocol": "rest",
"access": "read-only"
},
],
"binaries": []
}
}
});
let warnings = expand_access_presets(&mut data);
let endpoints = data["network_policies"]["test"]["endpoints"]
.as_array()
.unwrap();
// Invalid entry is left untouched.
assert_eq!(endpoints[0], serde_json::json!("not-an-object"));
// Valid preset endpoint is expanded.
let rules = endpoints[1]["rules"].as_array().unwrap();
assert_eq!(rules.len(), 3);
let methods: Vec<&str> = rules
.iter()
.map(|r| r["allow"]["method"].as_str().unwrap())
.collect();
assert!(methods.contains(&"GET"));
assert!(methods.contains(&"HEAD"));
assert!(methods.contains(&"OPTIONS"));
assert!(warnings.is_empty(), "expected no warnings: {warnings:?}");
}

#[test]
fn expand_graphql_readonly_preset() {
let mut data = serde_json::json!({
Expand Down
187 changes: 183 additions & 4 deletions crates/openshell-supervisor-network/src/opa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1118,7 +1118,14 @@ fn normalize_endpoint_ports(data: &mut serde_json::Value) {
continue;
};

// If "ports" already exists and is non-empty, keep it.
// If "ports" already exists, filter out numeric zero values so
// OPA never sees a zero port. Non-numeric entries are left intact
// so downstream validation can fail closed on malformed input
// rather than silently dropping it.
if let Some(ports) = ep_obj.get_mut("ports").and_then(|v| v.as_array_mut()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gator-agent

Critical (CWE-20): This normalizer runs only from preprocess_yaml_data; production OpaEngine::from_proto loads proto_to_opa_data_json without calling it. validate_l7_policies filters only a temporary vector, so a CLI policy with ports: [0, 443] still reaches OPA containing zero during normal sandbox load/reload. Please filter in proto_to_opa_data_json or apply shared normalization to proto-generated JSON, then add an OpaEngine::from_proto regression test.

ports.retain(|p| !p.as_u64().is_some_and(|n| n == 0));
}

let has_ports = ep_obj
.get("ports")
.and_then(|v| v.as_array())
Expand Down Expand Up @@ -1493,10 +1500,12 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St
.endpoints
.iter()
.map(|e| {
// Normalize port/ports: ports takes precedence, then
// single port promoted to array. Rego always sees "ports".
// Normalize port/ports: filter zero ports first so OPA
// never sees them, then a present `ports` array takes
// precedence over the scalar `port`. Rego always sees "ports".
let filtered: Vec<u32> = e.ports.iter().copied().filter(|&p| p > 0).collect();
let ports: Vec<u32> = if !e.ports.is_empty() {
e.ports.clone()
filtered
} else if e.port > 0 {
vec![e.port]
} else {
Expand Down Expand Up @@ -7705,4 +7714,174 @@ network_policies:
cmdline_paths: vec![],
}
}

#[test]
fn normalize_endpoint_ports_filters_zero_values() {
let mut data = serde_json::json!({
"network_policies": {
"p": {
"endpoints": [
{"host": "h1.test", "ports": [0, 443]},
{"host": "h2.test", "ports": [0]},
{"host": "h3.test", "port": 0},
{"host": "h4.test", "port": 8080},
]
}
}
});
normalize_endpoint_ports(&mut data);
let endpoints = data["network_policies"]["p"]["endpoints"]
.as_array()
.unwrap();

// Mixed array: zero removed, positive kept.
assert_eq!(endpoints[0]["ports"], serde_json::json!([443]));
// All-zero array: becomes empty, no fallback port.
assert_eq!(endpoints[1]["ports"], serde_json::json!([]));
assert!(endpoints[1].get("port").is_none());
// Zero scalar port: not promoted, removed.
assert!(endpoints[2].get("ports").is_none());
assert!(endpoints[2].get("port").is_none());
// Positive scalar port: promoted to ports array.
assert_eq!(endpoints[3]["ports"], serde_json::json!([8080]));
assert!(endpoints[3].get("port").is_none());
}

#[test]
fn normalize_endpoint_ports_skips_non_object_endpoints() {
let mut data = serde_json::json!({
"network_policies": {
"p": {
"endpoints": [
"not-an-object",
{"host": "h.test", "port": 443},
42,
]
}
}
});
normalize_endpoint_ports(&mut data);
let endpoints = data["network_policies"]["p"]["endpoints"]
.as_array()
.unwrap();

// Non-object entries are left untouched rather than panicking.
assert_eq!(endpoints[0], serde_json::json!("not-an-object"));
assert_eq!(endpoints[1]["ports"], serde_json::json!([443]));
assert_eq!(endpoints[2], serde_json::json!(42));
}

#[test]
fn normalize_endpoint_ports_empty_array_after_filtering() {
let mut data = serde_json::json!({
"network_policies": {
"p": {
"endpoints": [
{"host": "h.test", "ports": [0], "port": 8080},
]
}
}
});
normalize_endpoint_ports(&mut data);
let endpoints = data["network_policies"]["p"]["endpoints"]
.as_array()
.unwrap();
// Zero-only ports array becomes empty; scalar port is promoted.
assert_eq!(endpoints[0]["ports"], serde_json::json!([8080]));
assert!(endpoints[0].get("port").is_none());
}

#[test]
fn normalize_endpoint_ports_preserves_non_numeric_entries() {
let mut data = serde_json::json!({
"network_policies": {
"p": {
"endpoints": [
{"host": "h.test", "ports": [0, "bad", 443]},
]
}
}
});
normalize_endpoint_ports(&mut data);
let endpoints = data["network_policies"]["p"]["endpoints"]
.as_array()
.unwrap();
// Numeric zeros are removed; non-numeric entries are preserved so
// downstream validation can fail closed on malformed input.
assert_eq!(endpoints[0]["ports"], serde_json::json!(["bad", 443]));
}

fn proto_with_endpoint_ports(port: u32, ports: Vec<u32>) -> ProtoSandboxPolicy {
let mut network_policies = std::collections::HashMap::new();
network_policies.insert(
"p".to_string(),
NetworkPolicyRule {
name: "p".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port,
ports,
..Default::default()
}],
binaries: vec![],
},
);
ProtoSandboxPolicy {
version: 1,
filesystem: None,
landlock: None,
process: None,
network_policies,
network_middlewares: std::collections::HashMap::default(),
}
}

#[test]
fn proto_to_opa_data_json_filters_zero_ports_in_production_path() {
// Mixed array: zero removed, positive kept.
let proto = proto_with_endpoint_ports(0, vec![0, 443]);
let parsed: serde_json::Value =
serde_json::from_str(&proto_to_opa_data_json(&proto, 0)).unwrap();
assert_eq!(
parsed["network_policies"]["p"]["endpoints"][0]["ports"],
serde_json::json!([443])
);

// Zero-only array: becomes empty, no fallback to scalar port.
let proto = proto_with_endpoint_ports(0, vec![0]);
let parsed: serde_json::Value =
serde_json::from_str(&proto_to_opa_data_json(&proto, 0)).unwrap();
assert_eq!(
parsed["network_policies"]["p"]["endpoints"][0]["ports"],
serde_json::json!([])
);

// Zero scalar port: not promoted.
let proto = proto_with_endpoint_ports(0, vec![]);
let parsed: serde_json::Value =
serde_json::from_str(&proto_to_opa_data_json(&proto, 0)).unwrap();
assert_eq!(
parsed["network_policies"]["p"]["endpoints"][0]["ports"],
serde_json::json!([])
);

// Positive scalar port with zero-only array: present `ports` wins,
// so the scalar port is NOT promoted.
let proto = proto_with_endpoint_ports(8080, vec![0]);
let parsed: serde_json::Value =
serde_json::from_str(&proto_to_opa_data_json(&proto, 0)).unwrap();
assert_eq!(
parsed["network_policies"]["p"]["endpoints"][0]["ports"],
serde_json::json!([])
);

// Positive scalar port with positive array: array wins.
let proto = proto_with_endpoint_ports(8080, vec![443]);
let parsed: serde_json::Value =
serde_json::from_str(&proto_to_opa_data_json(&proto, 0)).unwrap();
assert_eq!(
parsed["network_policies"]["p"]["endpoints"][0]["ports"],
serde_json::json!([443])
);
}
}
Loading
Loading