Skip to content

Commit b25c1a5

Browse files
committed
fix(sandbox): fail closed on ambiguous JSON-RPC requests
Reject ambiguous JSON-RPC params before policy evaluation by refusing literal dotted object keys and flattened selector collisions. Force-deny JSON-RPC parse errors in the forward-proxy path so broad REST-style access presets cannot bypass malformed JSON-RPC bodies. Require JSON-RPC 2.0 request objects, use JSON-RPC-specific forward audit logs with RPC methods, params digest, and policy generation, and reject unsupported json_rpc YAML knobs instead of accepting unused fields. Signed-off-by: Kris Hicks <khicks@nvidia.com>
1 parent f1d3c15 commit b25c1a5

7 files changed

Lines changed: 233 additions & 56 deletions

File tree

architecture/sandbox.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ For inspected HTTP traffic, the proxy can enforce REST method/path rules,
5353
WebSocket upgrade and text-message rules, GraphQL operation rules, and
5454
JSON-RPC method and params rules on sandbox-to-server request bodies. JSON-RPC
5555
request inspection buffers up to the endpoint `json_rpc.max_body_bytes` limit.
56+
Literal dotted keys in JSON-RPC params are rejected before policy evaluation so
57+
they cannot be confused with flattened nested selector paths.
5658
JSON-RPC responses and server-to-client MCP messages on response or SSE streams
5759
are relayed but are not currently parsed for policy enforcement.
5860

crates/openshell-policy/src/lib.rs

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -156,18 +156,10 @@ fn is_zero_u32(v: &u32) -> bool {
156156
struct JsonRpcConfigDef {
157157
#[serde(default, skip_serializing_if = "is_zero_u32")]
158158
max_body_bytes: u32,
159-
#[serde(default, skip_serializing_if = "String::is_empty")]
160-
on_parse_error: String,
161-
#[serde(default, skip_serializing_if = "String::is_empty")]
162-
batch_policy: String,
163159
}
164160

165161
fn json_rpc_config_from_proto(max_body_bytes: u32) -> Option<JsonRpcConfigDef> {
166-
(max_body_bytes > 0).then_some(JsonRpcConfigDef {
167-
max_body_bytes,
168-
on_parse_error: String::new(),
169-
batch_policy: String::new(),
170-
})
162+
(max_body_bytes > 0).then_some(JsonRpcConfigDef { max_body_bytes })
171163
}
172164

173165
#[derive(Debug, Serialize, Deserialize)]
@@ -1777,6 +1769,31 @@ network_policies:
17771769
assert_eq!(ep.json_rpc_max_body_bytes, 131_072);
17781770
}
17791771

1772+
#[test]
1773+
fn parse_rejects_unsupported_json_rpc_config_fields() {
1774+
let yaml = r"
1775+
version: 1
1776+
network_policies:
1777+
mcp:
1778+
endpoints:
1779+
- host: mcp.example.com
1780+
port: 443
1781+
protocol: json-rpc
1782+
json_rpc:
1783+
max_body_bytes: 131072
1784+
on_parse_error: deny
1785+
batch_policy: all
1786+
access: full
1787+
binaries:
1788+
- path: /usr/bin/curl
1789+
";
1790+
1791+
assert!(
1792+
parse_sandbox_policy(yaml).is_err(),
1793+
"unsupported json_rpc fields must not be silently accepted"
1794+
);
1795+
}
1796+
17801797
#[test]
17811798
fn round_trip_preserves_websocket_credential_rewrite() {
17821799
let yaml = r"

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

Lines changed: 130 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,15 @@ pub fn parse_jsonrpc_body(body: &[u8]) -> JsonRpcRequestInfo {
9090
}
9191
let mut calls = Vec::new();
9292
for item in &items {
93-
let Some(call) = parse_jsonrpc_call(item) else {
94-
return JsonRpcRequestInfo {
95-
calls: Vec::new(),
96-
is_batch: true,
97-
error: Some("batch item missing or non-string 'method' field".to_string()),
98-
};
93+
let call = match parse_jsonrpc_call(item) {
94+
Ok(call) => call,
95+
Err(error) => {
96+
return JsonRpcRequestInfo {
97+
calls: Vec::new(),
98+
is_batch: true,
99+
error: Some(format!("batch item invalid: {error}")),
100+
};
101+
}
99102
};
100103
calls.push(call);
101104
}
@@ -106,12 +109,15 @@ pub fn parse_jsonrpc_body(body: &[u8]) -> JsonRpcRequestInfo {
106109
};
107110
}
108111

109-
let Some(call) = parse_jsonrpc_call(&value) else {
110-
return JsonRpcRequestInfo {
111-
calls: Vec::new(),
112-
is_batch: false,
113-
error: Some("missing or non-string 'method' field".to_string()),
114-
};
112+
let call = match parse_jsonrpc_call(&value) {
113+
Ok(call) => call,
114+
Err(error) => {
115+
return JsonRpcRequestInfo {
116+
calls: Vec::new(),
117+
is_batch: false,
118+
error: Some(error),
119+
};
120+
}
115121
};
116122
JsonRpcRequestInfo {
117123
calls: vec![call],
@@ -120,20 +126,33 @@ pub fn parse_jsonrpc_body(body: &[u8]) -> JsonRpcRequestInfo {
120126
}
121127
}
122128

123-
fn parse_jsonrpc_call(value: &serde_json::Value) -> Option<JsonRpcCallInfo> {
124-
let method = value.get("method").and_then(|m| m.as_str())?;
125-
Some(JsonRpcCallInfo {
129+
fn parse_jsonrpc_call(value: &serde_json::Value) -> std::result::Result<JsonRpcCallInfo, String> {
130+
let version = value
131+
.get("jsonrpc")
132+
.and_then(|v| v.as_str())
133+
.ok_or_else(|| "missing or non-string 'jsonrpc' field".to_string())?;
134+
if version != "2.0" {
135+
return Err(format!("unsupported JSON-RPC version '{version}'"));
136+
}
137+
let method = value
138+
.get("method")
139+
.and_then(|m| m.as_str())
140+
.ok_or_else(|| "missing or non-string 'method' field".to_string())?;
141+
let params = value
142+
.get("params")
143+
.map_or_else(|| Ok(HashMap::new()), flatten_jsonrpc_params)?;
144+
Ok(JsonRpcCallInfo {
126145
method: method.to_string(),
127-
params: value
128-
.get("params")
129-
.map_or_else(HashMap::new, flatten_jsonrpc_params),
146+
params,
130147
})
131148
}
132149

133-
fn flatten_jsonrpc_params(value: &serde_json::Value) -> HashMap<String, String> {
150+
fn flatten_jsonrpc_params(
151+
value: &serde_json::Value,
152+
) -> std::result::Result<HashMap<String, String>, String> {
134153
let mut params = HashMap::new();
135-
flatten_json_value("", value, &mut params);
136-
params
154+
flatten_json_value("", value, &mut params)?;
155+
Ok(params)
137156
}
138157

139158
fn canonical_params_map(params: &HashMap<String, String>) -> BTreeMap<String, String> {
@@ -148,29 +167,50 @@ fn sha256_json(value: &impl serde::Serialize) -> String {
148167
hex::encode(Sha256::digest(&encoded))
149168
}
150169

151-
fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
170+
fn flatten_json_value(
171+
prefix: &str,
172+
value: &serde_json::Value,
173+
out: &mut HashMap<String, String>,
174+
) -> std::result::Result<(), String> {
152175
match value {
153176
serde_json::Value::Object(map) => {
154177
for (key, child) in map {
178+
if key.contains('.') {
179+
return Err(format!(
180+
"ambiguous dotted params key '{key}' is not allowed"
181+
));
182+
}
155183
let next = if prefix.is_empty() {
156184
key.clone()
157185
} else {
158186
format!("{prefix}.{key}")
159187
};
160-
flatten_json_value(&next, child, out);
188+
flatten_json_value(&next, child, out)?;
161189
}
162190
}
163191
serde_json::Value::String(s) if !prefix.is_empty() => {
164-
out.insert(prefix.to_string(), s.clone());
192+
insert_flattened_param(out, prefix, s.clone())?;
165193
}
166194
serde_json::Value::Number(n) if !prefix.is_empty() => {
167-
out.insert(prefix.to_string(), n.to_string());
195+
insert_flattened_param(out, prefix, n.to_string())?;
168196
}
169197
serde_json::Value::Bool(b) if !prefix.is_empty() => {
170-
out.insert(prefix.to_string(), b.to_string());
198+
insert_flattened_param(out, prefix, b.to_string())?;
171199
}
172200
_ => {}
173201
}
202+
Ok(())
203+
}
204+
205+
fn insert_flattened_param(
206+
out: &mut HashMap<String, String>,
207+
key: &str,
208+
value: String,
209+
) -> std::result::Result<(), String> {
210+
if out.insert(key.to_string(), value).is_some() {
211+
return Err(format!("ambiguous params key collision at '{key}'"));
212+
}
213+
Ok(())
174214
}
175215

176216
#[cfg(test)]
@@ -205,6 +245,70 @@ mod tests {
205245
);
206246
}
207247

248+
#[test]
249+
fn rejects_literal_dotted_param_keys() {
250+
let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"arguments.scope":"workspace/other","arguments":{"scope":"workspace/main"}}}"#;
251+
let info = parse_jsonrpc_body(body);
252+
253+
assert!(info.calls.is_empty());
254+
assert!(
255+
info.error
256+
.as_deref()
257+
.is_some_and(|error| error.contains("ambiguous dotted params key")),
258+
"expected dotted params key error, got {info:?}"
259+
);
260+
}
261+
262+
#[test]
263+
fn rejects_requests_missing_jsonrpc_version() {
264+
let body = br#"{"id":1,"method":"tools/list"}"#;
265+
let info = parse_jsonrpc_body(body);
266+
267+
assert!(info.calls.is_empty());
268+
assert_eq!(
269+
info.error.as_deref(),
270+
Some("missing or non-string 'jsonrpc' field")
271+
);
272+
}
273+
274+
#[test]
275+
fn rejects_batch_items_missing_jsonrpc_version() {
276+
let body = br#"[
277+
{"jsonrpc":"2.0","id":1,"method":"tools/list"},
278+
{"id":2,"method":"tools/call","params":{"name":"read_status"}}
279+
]"#;
280+
let info = parse_jsonrpc_body(body);
281+
282+
assert!(info.calls.is_empty());
283+
assert!(info.is_batch);
284+
assert_eq!(
285+
info.error.as_deref(),
286+
Some("batch item invalid: missing or non-string 'jsonrpc' field")
287+
);
288+
}
289+
290+
#[test]
291+
fn rejects_unsupported_jsonrpc_version() {
292+
let body = br#"{"jsonrpc":"1.0","id":1,"method":"tools/list"}"#;
293+
let info = parse_jsonrpc_body(body);
294+
295+
assert!(info.calls.is_empty());
296+
assert_eq!(
297+
info.error.as_deref(),
298+
Some("unsupported JSON-RPC version '1.0'")
299+
);
300+
}
301+
302+
#[test]
303+
fn detects_flattened_param_collisions() {
304+
let mut params = HashMap::from([("arguments.scope".to_string(), "first".to_string())]);
305+
306+
let error = insert_flattened_param(&mut params, "arguments.scope", "second".to_string())
307+
.expect_err("duplicate flattened key should be ambiguous");
308+
309+
assert!(error.contains("ambiguous params key collision"));
310+
}
311+
208312
#[test]
209313
fn parses_valid_batch_without_error() {
210314
let body = br#"[

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,7 +1281,7 @@ fn graphql_log_summary(info: &crate::l7::graphql::GraphqlRequestInfo) -> String
12811281
format!("graphql_ops={}", ops.join(";"))
12821282
}
12831283

1284-
fn jsonrpc_log_message(
1284+
pub(crate) fn jsonrpc_log_message(
12851285
decision: &str,
12861286
http_method: &str,
12871287
endpoint: &str,
@@ -1296,7 +1296,7 @@ fn jsonrpc_log_message(
12961296
)
12971297
}
12981298

1299-
fn jsonrpc_methods_for_log(info: &crate::l7::jsonrpc::JsonRpcRequestInfo) -> String {
1299+
pub(crate) fn jsonrpc_methods_for_log(info: &crate::l7::jsonrpc::JsonRpcRequestInfo) -> String {
13001300
if info.calls.is_empty() {
13011301
return "-".to_string();
13021302
}

0 commit comments

Comments
 (0)