@@ -1662,14 +1662,11 @@ where
16621662 }
16631663}
16641664
1665- fn select_l7_config_for_path<'a>(
1665+ pub(crate) fn select_l7_config_for_path<'a>(
16661666 configs: &'a [L7EndpointConfig],
16671667 path: &str,
16681668) -> Option<&'a L7EndpointConfig> {
1669- configs
1670- .iter()
1671- .filter(|config| config.matches_path(path))
1672- .max_by_key(|config| config.path_specificity())
1669+ openshell_core::endpoint_path::select_most_specific(configs, path, |config| &config.path)
16731670}
16741671
16751672fn emit_l7_request_log(
@@ -10565,6 +10562,157 @@ network_policies:
1056510562 let _ = tokio::time::timeout(std::time::Duration::from_secs(1), relay).await;
1056610563 }
1056710564
10565+ #[tokio::test]
10566+ async fn route_protocol_confines_rpc_calls_beside_broad_rest() {
10567+ for protocol in ["json-rpc", "mcp"] {
10568+ let rule = if protocol == "mcp" {
10569+ "{ method: tools/call, tool: read_status }"
10570+ } else {
10571+ "{ method: status.get }"
10572+ };
10573+ let yaml = format!(
10574+ r#"
10575+ version: 1
10576+ network_policies:
10577+ rest_api:
10578+ endpoints:
10579+ - host: "*.routes.test"
10580+ port: 8000
10581+ protocol: rest
10582+ enforcement: enforce
10583+ access: read-write
10584+ binaries: [{{ path: /usr/bin/python3 }}]
10585+ rpc_api:
10586+ endpoints:
10587+ - host: api.routes.test
10588+ port: 8000
10589+ path: /services/**
10590+ protocol: {protocol}
10591+ enforcement: enforce
10592+ rules: [{{ allow: {rule} }}]
10593+ binaries: [{{ path: /usr/bin/python3 }}]
10594+ "#
10595+ );
10596+ for (path, body, expected) in [
10597+ (
10598+ "/services",
10599+ if protocol == "mcp" {
10600+ r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_status","arguments":{}}}"#
10601+ } else {
10602+ r#"{"jsonrpc":"2.0","id":1,"method":"status.get"}"#
10603+ },
10604+ "200 OK",
10605+ ),
10606+ (
10607+ "/services/catalog",
10608+ if protocol == "mcp" {
10609+ r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"write_status","arguments":{}}}"#
10610+ } else {
10611+ r#"{"jsonrpc":"2.0","id":2,"method":"status.set"}"#
10612+ },
10613+ "403 Forbidden",
10614+ ),
10615+ ("/public", "{}", "200 OK"),
10616+ ] {
10617+ let proto = openshell_policy::parse_sandbox_policy(&yaml).unwrap();
10618+ let engine = OpaEngine::from_proto(&proto).unwrap();
10619+ let input = NetworkInput {
10620+ host: "api.routes.test".into(),
10621+ port: 8000,
10622+ binary_path: PathBuf::from("/usr/bin/python3"),
10623+ binary_sha256: "unused".into(),
10624+ ancestors: vec![],
10625+ cmdline_paths: vec![],
10626+ };
10627+ let (endpoints, generation) = engine
10628+ .query_endpoint_configs_with_generation(&input)
10629+ .unwrap();
10630+ let configs = endpoints
10631+ .iter()
10632+ .filter_map(crate::l7::parse_l7_config)
10633+ .collect::<Vec<_>>();
10634+ let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap();
10635+ let ctx = L7EvalContext {
10636+ host: input.host,
10637+ port: 8000,
10638+ request_default_port: Some(8000),
10639+ policy_name: "rpc_api".into(),
10640+ binary_path: "/usr/bin/python3".into(),
10641+ ..Default::default()
10642+ };
10643+ let (mut app, mut client) = tokio::io::duplex(8192);
10644+ let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192);
10645+ let relay = tokio::spawn(async move {
10646+ relay_with_route_selection(
10647+ &configs,
10648+ tunnel_engine,
10649+ &mut client,
10650+ &mut relay_upstream,
10651+ &ctx,
10652+ )
10653+ .await
10654+ });
10655+ // An accepting upstream makes a policy bypass produce a 200.
10656+ let server = tokio::spawn(async move {
10657+ let mut received = Vec::new();
10658+ let mut buffer = [0; 4096];
10659+ loop {
10660+ let n = upstream.read(&mut buffer).await.unwrap();
10661+ if n == 0 {
10662+ return received;
10663+ }
10664+ received.extend_from_slice(&buffer[..n]);
10665+ if let Some(head) =
10666+ received.windows(4).position(|window| window == b"\r\n\r\n")
10667+ {
10668+ let headers = std::str::from_utf8(&received[..head]).unwrap();
10669+ let length = headers
10670+ .lines()
10671+ .find_map(|line| {
10672+ let (name, value) = line.split_once(':')?;
10673+ name.eq_ignore_ascii_case("content-length")
10674+ .then(|| value.trim().parse::<usize>().unwrap())
10675+ })
10676+ .unwrap();
10677+ if received.len() >= head + 4 + length {
10678+ upstream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap();
10679+ return received;
10680+ }
10681+ }
10682+ }
10683+ });
10684+ app.write_all(format!(
10685+ "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()
10686+ ).as_bytes()).await.unwrap();
10687+ let mut response = Vec::new();
10688+ tokio::time::timeout(
10689+ std::time::Duration::from_secs(5),
10690+ app.read_to_end(&mut response),
10691+ )
10692+ .await
10693+ .expect("relay must answer")
10694+ .unwrap();
10695+ assert!(
10696+ String::from_utf8_lossy(&response).starts_with(&format!("HTTP/1.1 {expected}")),
10697+ "{protocol} {path}: {}",
10698+ String::from_utf8_lossy(&response)
10699+ );
10700+ drop(app);
10701+ tokio::time::timeout(std::time::Duration::from_secs(5), relay)
10702+ .await
10703+ .unwrap()
10704+ .unwrap()
10705+ .unwrap();
10706+ let received = server.await.unwrap();
10707+ assert_eq!(
10708+ received.is_empty(),
10709+ expected == "403 Forbidden",
10710+ "{protocol} {path}: denied calls must not reach upstream"
10711+ );
10712+ }
10713+ }
10714+ }
10715+
1056810716 /// A `tools/call` that no JSON-RPC-family fixture below allows.
1056910717 const UNALLOWED_TOOL_CALL: &[u8] =
1057010718 br#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_resource","arguments":{}}}"#;
0 commit comments