Skip to content

Commit 03267db

Browse files
committed
feat(l7): support JSON-RPC batch calls
Parse JSON-RPC batch arrays into per-call metadata and evaluate each batch item with the existing method and params policy rules. Deny the whole batch when any call is denied. Signed-off-by: Kris Hicks <khicks@nvidia.com>
1 parent 727a7f2 commit 03267db

2 files changed

Lines changed: 184 additions & 42 deletions

File tree

crates/openshell-sandbox/src/l7/jsonrpc.rs

Lines changed: 73 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,15 @@ pub(crate) async fn parse_jsonrpc_http_request<C: AsyncRead + AsyncWrite + Unpin
3131

3232
#[derive(Debug, Clone, PartialEq, Eq)]
3333
pub struct JsonRpcRequestInfo {
34-
pub method: Option<String>,
35-
pub params: HashMap<String, String>,
34+
pub calls: Vec<JsonRpcCallInfo>,
35+
pub is_batch: bool,
3636
pub error: Option<String>,
3737
}
3838

39-
/// Returns true if the parsed request's method matches the given `rpc_method` rule pattern.
40-
///
41-
/// An empty `rpc_method` pattern matches any method.
42-
pub fn rpc_method_rule_matches(info: &JsonRpcRequestInfo, rpc_method: &str) -> bool {
43-
if rpc_method.is_empty() {
44-
return true;
45-
}
46-
info.method.as_deref() == Some(rpc_method)
39+
#[derive(Debug, Clone, PartialEq, Eq)]
40+
pub struct JsonRpcCallInfo {
41+
pub method: String,
42+
pub params: HashMap<String, String>,
4743
}
4844

4945
/// Parse a JSON-RPC 2.0 request body and extract the `method` field.
@@ -53,25 +49,60 @@ pub fn rpc_method_rule_matches(info: &JsonRpcRequestInfo, rpc_method: &str) -> b
5349
pub fn parse_jsonrpc_body(body: &[u8]) -> JsonRpcRequestInfo {
5450
let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
5551
return JsonRpcRequestInfo {
56-
method: None,
57-
params: HashMap::new(),
52+
calls: Vec::new(),
53+
is_batch: false,
5854
error: Some("invalid JSON".to_string()),
5955
};
6056
};
61-
let Some(method) = value.get("method").and_then(|m| m.as_str()) else {
57+
58+
if let serde_json::Value::Array(items) = value {
59+
if items.is_empty() {
60+
return JsonRpcRequestInfo {
61+
calls: Vec::new(),
62+
is_batch: true,
63+
error: Some("empty batch".to_string()),
64+
};
65+
}
66+
let mut calls = Vec::new();
67+
for item in &items {
68+
let Some(call) = parse_jsonrpc_call(item) else {
69+
return JsonRpcRequestInfo {
70+
calls: Vec::new(),
71+
is_batch: true,
72+
error: Some("batch item missing or non-string 'method' field".to_string()),
73+
};
74+
};
75+
calls.push(call);
76+
}
77+
return JsonRpcRequestInfo {
78+
calls,
79+
is_batch: true,
80+
error: None,
81+
};
82+
}
83+
84+
let Some(call) = parse_jsonrpc_call(&value) else {
6285
return JsonRpcRequestInfo {
63-
method: None,
64-
params: HashMap::new(),
86+
calls: Vec::new(),
87+
is_batch: false,
6588
error: Some("missing or non-string 'method' field".to_string()),
6689
};
6790
};
6891
JsonRpcRequestInfo {
69-
method: Some(method.to_string()),
92+
calls: vec![call],
93+
is_batch: false,
94+
error: None,
95+
}
96+
}
97+
98+
fn parse_jsonrpc_call(value: &serde_json::Value) -> Option<JsonRpcCallInfo> {
99+
let method = value.get("method").and_then(|m| m.as_str())?;
100+
Some(JsonRpcCallInfo {
101+
method: method.to_string(),
70102
params: value
71103
.get("params")
72104
.map_or_else(HashMap::new, flatten_jsonrpc_params),
73-
error: None,
74-
}
105+
})
75106
}
76107

77108
fn flatten_jsonrpc_params(value: &serde_json::Value) -> HashMap<String, String> {
@@ -113,39 +144,45 @@ mod tests {
113144
fn parses_method_from_request_body() {
114145
let body = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
115146
let info = parse_jsonrpc_body(body);
116-
assert_eq!(info.method.as_deref(), Some("initialize"));
147+
assert_eq!(
148+
info.calls.first().map(|call| call.method.as_str()),
149+
Some("initialize")
150+
);
151+
assert_eq!(info.calls.len(), 1);
152+
assert!(!info.is_batch);
117153
assert!(info.error.is_none());
118154
}
119155

120156
#[test]
121157
fn flattens_object_params_for_policy_matching() {
122158
let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"submit_report","arguments":{"scope":"workspace/main"}}}"#;
123159
let info = parse_jsonrpc_body(body);
160+
let params = &info.calls.first().expect("single request call").params;
124161
assert_eq!(
125-
info.params.get("name").map(String::as_str),
162+
params.get("name").map(String::as_str),
126163
Some("submit_report")
127164
);
128165
assert_eq!(
129-
info.params.get("arguments.scope").map(String::as_str),
166+
params.get("arguments.scope").map(String::as_str),
130167
Some("workspace/main")
131168
);
132169
}
133170

134171
#[test]
135-
fn rpc_method_rule_empty_matches_any() {
136-
let info = parse_jsonrpc_body(br#"{"jsonrpc":"2.0","id":1,"method":"tools/call"}"#);
137-
assert!(rpc_method_rule_matches(&info, ""));
138-
}
139-
140-
#[test]
141-
fn rpc_method_rule_matches_exact_method() {
142-
let info = parse_jsonrpc_body(br#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#);
143-
assert!(rpc_method_rule_matches(&info, "initialize"));
144-
}
145-
146-
#[test]
147-
fn rpc_method_rule_does_not_match_different_method() {
148-
let info = parse_jsonrpc_body(br#"{"jsonrpc":"2.0","id":1,"method":"tools/call"}"#);
149-
assert!(!rpc_method_rule_matches(&info, "initialize"));
172+
fn parses_valid_batch_without_error() {
173+
let body = br#"[
174+
{"jsonrpc":"2.0","id":1,"method":"tools/list"},
175+
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_status"}}
176+
]"#;
177+
let info = parse_jsonrpc_body(body);
178+
assert!(info.error.is_none());
179+
assert!(info.is_batch);
180+
assert_eq!(info.calls.len(), 2);
181+
assert_eq!(info.calls[0].method, "tools/list");
182+
assert_eq!(info.calls[1].method, "tools/call");
183+
assert_eq!(
184+
info.calls[1].params.get("name").map(String::as_str),
185+
Some("read_status")
186+
);
150187
}
151188
}

crates/openshell-sandbox/src/l7/relay.rs

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -981,7 +981,11 @@ where
981981
SeverityId::Informational,
982982
),
983983
};
984-
let rpc_method = jsonrpc_info.method.as_deref().unwrap_or("-");
984+
let rpc_method = jsonrpc_info
985+
.calls
986+
.first()
987+
.map(|call| call.method.as_str())
988+
.unwrap_or("-");
985989
let event = HttpActivityBuilder::new(crate::ocsf_ctx())
986990
.activity(ActivityId::Other)
987991
.action(action_id)
@@ -1295,6 +1299,33 @@ pub fn evaluate_l7_request(
12951299
engine: &TunnelPolicyEngine,
12961300
ctx: &L7EvalContext,
12971301
request: &L7RequestInfo,
1302+
) -> Result<(bool, String)> {
1303+
if let Some(jsonrpc) = &request.jsonrpc
1304+
&& jsonrpc.is_batch
1305+
&& !jsonrpc.calls.is_empty()
1306+
{
1307+
for call in &jsonrpc.calls {
1308+
let mut item_request = request.clone();
1309+
item_request.jsonrpc = Some(crate::l7::jsonrpc::JsonRpcRequestInfo {
1310+
calls: vec![call.clone()],
1311+
is_batch: false,
1312+
error: None,
1313+
});
1314+
let (allowed, reason) = evaluate_l7_request_once(engine, ctx, &item_request)?;
1315+
if !allowed {
1316+
return Ok((false, reason));
1317+
}
1318+
}
1319+
return Ok((true, String::new()));
1320+
}
1321+
1322+
evaluate_l7_request_once(engine, ctx, request)
1323+
}
1324+
1325+
fn evaluate_l7_request_once(
1326+
engine: &TunnelPolicyEngine,
1327+
ctx: &L7EvalContext,
1328+
request: &L7RequestInfo,
12981329
) -> Result<(bool, String)> {
12991330
if engine.is_stale() {
13001331
return Err(miette!(
@@ -1319,11 +1350,14 @@ pub fn evaluate_l7_request(
13191350
"path": request.target,
13201351
"query_params": request.query_params.clone(),
13211352
"graphql": request.graphql.clone(),
1322-
"jsonrpc": request.jsonrpc.as_ref().map(|j| serde_json::json!({
1323-
"method": j.method,
1324-
"params": j.params,
1325-
"error": j.error,
1326-
})),
1353+
"jsonrpc": request.jsonrpc.as_ref().map(|j| {
1354+
let call = if j.is_batch { None } else { j.calls.first() };
1355+
serde_json::json!({
1356+
"method": call.map(|call| call.method.as_str()),
1357+
"params": call.map(|call| call.params.clone()).unwrap_or_default(),
1358+
"error": j.error,
1359+
})
1360+
}),
13271361
}
13281362
});
13291363

@@ -1966,6 +2000,77 @@ network_policies:
19662000
assert!(reason.contains("WEBSOCKET_TEXT /ws not permitted"));
19672001
}
19682002

2003+
#[test]
2004+
fn jsonrpc_batch_evaluates_each_call() {
2005+
let data = r#"
2006+
network_policies:
2007+
jsonrpc_api:
2008+
name: jsonrpc_api
2009+
endpoints:
2010+
- host: api.example.test
2011+
port: 443
2012+
protocol: json-rpc
2013+
enforcement: enforce
2014+
rules:
2015+
- allow:
2016+
method: POST
2017+
path: "/mcp"
2018+
rpc_method: "tools/list"
2019+
- allow:
2020+
method: POST
2021+
path: "/mcp"
2022+
rpc_method: "tools/call"
2023+
params:
2024+
name: read_status
2025+
deny_rules:
2026+
- rpc_method: "tools/call"
2027+
params:
2028+
name: blocked_action
2029+
binaries:
2030+
- { path: /usr/bin/node }
2031+
"#;
2032+
let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap();
2033+
let tunnel_engine = engine
2034+
.clone_engine_for_tunnel(engine.current_generation())
2035+
.unwrap();
2036+
let ctx = L7EvalContext {
2037+
host: "api.example.test".into(),
2038+
port: 443,
2039+
policy_name: "jsonrpc_api".into(),
2040+
binary_path: "/usr/bin/node".into(),
2041+
ancestors: vec![],
2042+
cmdline_paths: vec![],
2043+
secret_resolver: None,
2044+
activity_tx: None,
2045+
dynamic_credentials: None,
2046+
token_grant_resolver: None,
2047+
};
2048+
let mut request = L7RequestInfo {
2049+
action: "POST".into(),
2050+
target: "/mcp".into(),
2051+
query_params: std::collections::HashMap::new(),
2052+
graphql: None,
2053+
jsonrpc: Some(crate::l7::jsonrpc::parse_jsonrpc_body(
2054+
br#"[
2055+
{"jsonrpc":"2.0","id":1,"method":"tools/list"},
2056+
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_status"}}
2057+
]"#,
2058+
)),
2059+
};
2060+
2061+
let (allowed, reason) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap();
2062+
assert!(allowed, "{reason}");
2063+
2064+
request.jsonrpc = Some(crate::l7::jsonrpc::parse_jsonrpc_body(
2065+
br#"[
2066+
{"jsonrpc":"2.0","id":1,"method":"tools/list"},
2067+
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"blocked_action"}}
2068+
]"#,
2069+
));
2070+
let (allowed, _) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap();
2071+
assert!(!allowed);
2072+
}
2073+
19692074
#[tokio::test]
19702075
async fn route_selected_websocket_upgrade_rejects_invalid_accept_without_forwarding_101() {
19712076
let data = r#"

0 commit comments

Comments
 (0)