Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion crates/openshell-core/src/endpoint_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,46 @@ pub fn matches(pattern: &str, path: &str) -> bool {
EndpointPathPattern::new(pattern).matches(path)
}

/// Select the most specific matching endpoint, retaining the last match on ties.
///
/// Specificity counts non-asterisk characters in the endpoint's path selector.
/// Callers filter candidates for protocol and connection eligibility first.
#[must_use]
pub fn select_most_specific<'a, T: 'a>(
endpoints: impl IntoIterator<Item = &'a T>,
path: &str,
selector: impl Fn(&T) -> &str,
) -> Option<&'a T> {
endpoints
.into_iter()
.filter(|endpoint| matches(selector(endpoint), path))
.max_by_key(|endpoint| selector(endpoint).chars().filter(|c| *c != '*').count())
}

#[cfg(test)]
mod tests {
use super::{EndpointPathPattern, matches};
use super::{EndpointPathPattern, matches, select_most_specific};

#[test]
fn route_selection_preserves_specificity_and_ties() {
let endpoints = [
("rest", ""),
("graphql", "/unrelated/exact"),
("json-rpc", "/services/**"),
("mcp", "/services/tools/**"),
("mcp-last", "/services/tools/**"),
];
for (path, protocol) in [
("/public", "rest"),
("/services", "json-rpc"),
("/services/tools/read", "mcp-last"),
("/unrelated/exact", "graphql"),
] {
let selected = select_most_specific(&endpoints, path, |endpoint| endpoint.1).unwrap();
assert_eq!(selected.0, protocol, "route {path}");
}
assert!(select_most_specific(&endpoints[1..], "/public", |endpoint| endpoint.1).is_none());
}

#[test]
fn matches_canonical_endpoint_patterns() {
Expand Down
34 changes: 34 additions & 0 deletions crates/openshell-prover/tests/runtime_parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,40 @@ fn runtime_engine_with_identity(policy: &str, require_binary_identity: bool) ->
}),
)
.expect("runtime endpoint path matcher should register");
engine
.add_extension(
"openshell.selected_l7_protocol".into(),
2,
Box::new(|params: Vec<Value>| {
// These fixtures use validated endpoint modes. Share route
// matching, ranking, and tie behavior with the runtime without
// depending on the supervisor's networking implementation.
let configs = params[0]
.as_array()?
.iter()
.filter_map(|config| {
let protocol = config["protocol"].as_string().ok()?.to_ascii_lowercase();
if !matches!(
protocol.as_str(),
"rest" | "websocket" | "graphql" | "sql" | "json-rpc" | "mcp"
) {
return None;
}
let path = config["path"]
.as_string()
.map_or("", |value| value.as_ref());
Some((protocol, path))
})
.collect::<Vec<_>>();
let selected = openshell_core::endpoint_path::select_most_specific(
&configs,
params[1].as_string()?,
|config| config.1,
);
Ok(selected.map_or(Value::Undefined, |config| Value::from(config.0.as_str())))
}),
)
.expect("runtime route protocol selector should register");
engine
.add_policy("sandbox-policy.rego".into(), SANDBOX_POLICY_REGO.into())
.expect("runtime Rego should compile");
Expand Down
18 changes: 18 additions & 0 deletions crates/openshell-supervisor-network/data/sandbox-policy.rego
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,27 @@ _policy_allowed_l7_endpoints(policy) := [ep |
some ep
ep := policy.endpoints[_]
endpoint_matches_l7_request(ep, input.network, input.request)
endpoint_uses_selected_request_protocol(ep)
request_allowed_for_endpoint(input.request, ep)
]

# HTTP authorization uses the same parser selection as the relay. A sibling
# endpoint can contribute an allow only when it uses the selected protocol;
# all matching endpoints of that protocol retain additive allow semantics.
# The native selector shares config parsing and path ranking with Rust.
_selected_l7_request_protocol := openshell.selected_l7_protocol(_matching_endpoint_configs, object.get(input.request, "path", ""))

endpoint_uses_selected_request_protocol(endpoint) if {
object.get(input.request, "command", "") == ""
lower(endpoint.protocol) == _selected_l7_request_protocol
}

# SQL commands do not use HTTP request-path routing.
endpoint_uses_selected_request_protocol(_) if {
command := object.get(input.request, "command", "")
command != ""
}

# Forwarding and credential selection share endpoint admission. A deny rule
# on any matching endpoint removes every otherwise admitted endpoint.
_admitted_l7_endpoints := {ep |
Expand Down
158 changes: 153 additions & 5 deletions crates/openshell-supervisor-network/src/l7/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1662,14 +1662,11 @@ where
}
}

fn select_l7_config_for_path<'a>(
pub(crate) fn select_l7_config_for_path<'a>(
configs: &'a [L7EndpointConfig],
path: &str,
) -> Option<&'a L7EndpointConfig> {
configs
.iter()
.filter(|config| config.matches_path(path))
.max_by_key(|config| config.path_specificity())
openshell_core::endpoint_path::select_most_specific(configs, path, |config| &config.path)
}

fn emit_l7_request_log(
Expand Down Expand Up @@ -10565,6 +10562,157 @@ network_policies:
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), relay).await;
}

#[tokio::test]
async fn route_protocol_confines_rpc_calls_beside_broad_rest() {
for protocol in ["json-rpc", "mcp"] {
let rule = if protocol == "mcp" {
"{ method: tools/call, tool: read_status }"
} else {
"{ method: status.get }"
};
let yaml = format!(
r#"
version: 1
network_policies:
rest_api:
endpoints:
- host: "*.routes.test"
port: 8000
protocol: rest
enforcement: enforce
access: read-write
binaries: [{{ path: /usr/bin/python3 }}]
rpc_api:
endpoints:
- host: api.routes.test
port: 8000
path: /services/**
protocol: {protocol}
enforcement: enforce
rules: [{{ allow: {rule} }}]
binaries: [{{ path: /usr/bin/python3 }}]
"#
);
for (path, body, expected) in [
(
"/services",
if protocol == "mcp" {
r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_status","arguments":{}}}"#
} else {
r#"{"jsonrpc":"2.0","id":1,"method":"status.get"}"#
},
"200 OK",
),
(
"/services/catalog",
if protocol == "mcp" {
r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"write_status","arguments":{}}}"#
} else {
r#"{"jsonrpc":"2.0","id":2,"method":"status.set"}"#
},
"403 Forbidden",
),
("/public", "{}", "200 OK"),
] {
let proto = openshell_policy::parse_sandbox_policy(&yaml).unwrap();
let engine = OpaEngine::from_proto(&proto).unwrap();
let input = NetworkInput {
host: "api.routes.test".into(),
port: 8000,
binary_path: PathBuf::from("/usr/bin/python3"),
binary_sha256: "unused".into(),
ancestors: vec![],
cmdline_paths: vec![],
};
let (endpoints, generation) = engine
.query_endpoint_configs_with_generation(&input)
.unwrap();
let configs = endpoints
.iter()
.filter_map(crate::l7::parse_l7_config)
.collect::<Vec<_>>();
let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap();
let ctx = L7EvalContext {
host: input.host,
port: 8000,
request_default_port: Some(8000),
policy_name: "rpc_api".into(),
binary_path: "/usr/bin/python3".into(),
..Default::default()
};
let (mut app, mut client) = tokio::io::duplex(8192);
let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192);
let relay = tokio::spawn(async move {
relay_with_route_selection(
&configs,
tunnel_engine,
&mut client,
&mut relay_upstream,
&ctx,
)
.await
});
// An accepting upstream makes a policy bypass produce a 200.
let server = tokio::spawn(async move {
let mut received = Vec::new();
let mut buffer = [0; 4096];
loop {
let n = upstream.read(&mut buffer).await.unwrap();
if n == 0 {
return received;
}
received.extend_from_slice(&buffer[..n]);
if let Some(head) =
received.windows(4).position(|window| window == b"\r\n\r\n")
{
let headers = std::str::from_utf8(&received[..head]).unwrap();
let length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap())
})
.unwrap();
if received.len() >= head + 4 + length {
upstream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap();
return received;
}
}
}
});
app.write_all(format!(
"POST {path} HTTP/1.1\r\nHost: api.routes.test:8000\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nMCP-Protocol-Version: 2025-11-25\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()
).as_bytes()).await.unwrap();
let mut response = Vec::new();
tokio::time::timeout(
std::time::Duration::from_secs(5),
app.read_to_end(&mut response),
)
.await
.expect("relay must answer")
.unwrap();
assert!(
String::from_utf8_lossy(&response).starts_with(&format!("HTTP/1.1 {expected}")),
"{protocol} {path}: {}",
String::from_utf8_lossy(&response)
);
drop(app);
tokio::time::timeout(std::time::Duration::from_secs(5), relay)
.await
.unwrap()
.unwrap()
.unwrap();
let received = server.await.unwrap();
assert_eq!(
received.is_empty(),
expected == "403 Forbidden",
"{protocol} {path}: denied calls must not reach upstream"
);
}
}
}

/// A `tools/call` that no JSON-RPC-family fixture below allows.
const UNALLOWED_TOOL_CALL: &[u8] =
br#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_resource","arguments":{}}}"#;
Expand Down
Loading
Loading