-
Notifications
You must be signed in to change notification settings - Fork 913
fix(mcp): reject JSON-RPC batches #2083
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,17 @@ use crate::l7::provider::{L7Provider, L7Request}; | |
|
|
||
| pub const DEFAULT_MAX_BODY_BYTES: usize = 64 * 1024; | ||
|
|
||
| /// Stable classification reason for a top-level array on an MCP endpoint. | ||
| pub(crate) const UNSUPPORTED_MCP_BATCH_REASON: &str = "unsupported_mcp_batch"; | ||
|
|
||
| /// Preserve stable classifier reasons while contextualizing ordinary JSON-RPC parse failures. | ||
| pub(crate) fn jsonrpc_parse_rejection_reason(error: &str) -> String { | ||
| if error == UNSUPPORTED_MCP_BATCH_REASON { | ||
| return error.to_string(); | ||
| } | ||
| format!("JSON-RPC request rejected: {error}") | ||
| } | ||
|
|
||
| /// Selects whether the parser should treat a JSON-RPC message as generic | ||
| /// JSON-RPC 2.0 or as an MCP message with MCP method/params validation. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
|
|
@@ -177,6 +188,17 @@ pub fn parse_jsonrpc_body_with_options( | |
| }; | ||
|
|
||
| if let serde_json::Value::Array(items) = value { | ||
| // Streamable HTTP carries one MCP message per POST. Reject the array | ||
| // itself before parsing members so every batch shape fails uniformly. | ||
| if inspection_options.mode == JsonRpcInspectionMode::Mcp { | ||
| return JsonRpcRequestInfo { | ||
| calls: Vec::new(), | ||
| is_batch: true, | ||
| receive_stream: false, | ||
| has_response: false, | ||
| error: Some(UNSUPPORTED_MCP_BATCH_REASON.to_string()), | ||
| }; | ||
| } | ||
| if items.is_empty() { | ||
| return JsonRpcRequestInfo { | ||
| calls: Vec::new(), | ||
|
|
@@ -491,6 +513,64 @@ mod tests { | |
| assert_eq!(call.params.len(), 1); | ||
| } | ||
|
|
||
| #[test] | ||
| fn mcp_batch_rejects_every_top_level_array_shape() { | ||
| let batches: &[&[u8]] = &[ | ||
| br"[]", | ||
| br#"[{"jsonrpc":"2.0","id":1,"method":"ping"}]"#, | ||
| br#"[{"jsonrpc":"2.0","method":"notifications/initialized"}]"#, | ||
| br#"[{"jsonrpc":"2.0","id":1,"result":{}}]"#, | ||
| br#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","id":1,"result":{}}]"#, | ||
| ]; | ||
|
|
||
| for body in batches { | ||
| let info = parse_jsonrpc_body(body, JsonRpcInspectionMode::Mcp); | ||
| assert!(info.calls.is_empty(), "batch calls escaped: {info:?}"); | ||
| assert!( | ||
| info.is_batch, | ||
| "array was not classified as a batch: {info:?}" | ||
| ); | ||
| assert!(!info.has_response, "batch members were inspected: {info:?}"); | ||
| assert_eq!(info.error.as_deref(), Some(UNSUPPORTED_MCP_BATCH_REASON)); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn mcp_batch_rejection_preserves_single_mcp_and_generic_batch_controls() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this test would have failed before this change, and it seems to duplicate |
||
| let singles: &[(&[u8], Option<&str>, bool)] = &[ | ||
| ( | ||
| br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#, | ||
| Some("ping"), | ||
| false, | ||
| ), | ||
| ( | ||
| br#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, | ||
| Some("notifications/initialized"), | ||
| false, | ||
| ), | ||
| (br#"{"jsonrpc":"2.0","id":1,"result":{}}"#, None, true), | ||
| ]; | ||
|
|
||
| for (body, expected_method, expected_response) in singles { | ||
| let mcp = parse_jsonrpc_body(body, JsonRpcInspectionMode::Mcp); | ||
| assert!(mcp.error.is_none(), "single MCP message failed: {mcp:?}"); | ||
| assert!(!mcp.is_batch); | ||
| assert_eq!( | ||
| mcp.calls.first().map(|call| call.method.as_str()), | ||
| *expected_method | ||
| ); | ||
| assert_eq!(mcp.has_response, *expected_response); | ||
| } | ||
|
|
||
| let generic = parse_jsonrpc_body( | ||
| br#"[{"jsonrpc":"2.0","id":1,"method":"reports.list"},{"jsonrpc":"2.0","method":"reports.observed"}]"#, | ||
| JsonRpcInspectionMode::JsonRpc, | ||
| ); | ||
| assert!(generic.error.is_none(), "generic batch failed: {generic:?}"); | ||
| assert!(generic.is_batch); | ||
| assert_eq!(generic.calls.len(), 2); | ||
| } | ||
|
|
||
| #[test] | ||
| fn mcp_mode_rejects_non_recommended_tool_names_by_default() { | ||
| let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read status","arguments":{}}}"#; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -467,14 +467,30 @@ fn forward_l7_hard_deny_reason( | |
| request_info.jsonrpc.as_ref().and_then(|info| { | ||
| info.error | ||
| .as_deref() | ||
| .map(|error| format!("JSON-RPC request rejected: {error}")) | ||
| .map(crate::l7::jsonrpc::jsonrpc_parse_rejection_reason) | ||
| .or_else(|| { | ||
| crate::l7::relay::jsonrpc_response_frame_hard_deny_reason(protocol, info) | ||
| }) | ||
| }) | ||
| }) | ||
| } | ||
|
|
||
| fn forward_l7_denial_detail( | ||
| force_deny: bool, | ||
| method: &str, | ||
| host: &str, | ||
| port: u16, | ||
| path: &str, | ||
| reason: &str, | ||
| ) -> String { | ||
| // Stable classifier reasons are machine-readable API details. Policy and | ||
| // ordinary parse denials retain request context for human diagnostics. | ||
| if force_deny && reason == crate::l7::jsonrpc::UNSUPPORTED_MCP_BATCH_REASON { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is another special-casing of UNSUPPORTED_MCP_BATCH_REASON which I think could be resolved through better design. |
||
| return reason.to_string(); | ||
| } | ||
| format!("{method} {host}:{port}{path} denied by L7 policy: {reason}") | ||
| } | ||
|
|
||
| /// Emit a denial event to the aggregator channel (if configured). | ||
| /// Used by `handle_tcp_connection` which owns `Option<Sender>`. | ||
| fn emit_denial( | ||
|
|
@@ -3791,14 +3807,11 @@ async fn handle_forward_proxy( | |
| &reason, | ||
| "forward-l7-deny", | ||
| ); | ||
| let detail = | ||
| forward_l7_denial_detail(force_deny, method, &host_lc, port, &path, &reason); | ||
| respond( | ||
| client, | ||
| &build_json_error_response( | ||
| 403, | ||
| "Forbidden", | ||
| "policy_denied", | ||
| &format!("{method} {host_lc}:{port}{path} denied by L7 policy: {reason}"), | ||
| ), | ||
| &build_json_error_response(403, "Forbidden", "policy_denied", &detail), | ||
| ) | ||
| .await?; | ||
| return Ok(()); | ||
|
|
@@ -4577,6 +4590,40 @@ network_policies: | |
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn forward_mcp_batch_denial_preserves_stable_detail() { | ||
| let jsonrpc = crate::l7::jsonrpc::parse_jsonrpc_body( | ||
| br#"[{"jsonrpc":"2.0","id":1,"method":"ping"}]"#, | ||
| crate::l7::jsonrpc::JsonRpcInspectionMode::Mcp, | ||
| ); | ||
| let request_info = crate::l7::L7RequestInfo { | ||
| action: "POST".to_string(), | ||
| target: "/mcp".to_string(), | ||
| query_params: std::collections::HashMap::new(), | ||
| graphql: None, | ||
| jsonrpc: Some(jsonrpc), | ||
| }; | ||
| let reason = forward_l7_hard_deny_reason(crate::l7::L7Protocol::Mcp, &request_info) | ||
| .expect("MCP batch should be a forward-proxy hard denial"); | ||
| let detail = | ||
| forward_l7_denial_detail(true, "POST", "mcp.example.test", 8000, "/mcp", &reason); | ||
| assert_eq!(detail, crate::l7::jsonrpc::UNSUPPORTED_MCP_BATCH_REASON); | ||
|
|
||
| let response = build_json_error_response(403, "Forbidden", "policy_denied", &detail); | ||
| let response = String::from_utf8(response).expect("denial response should be UTF-8"); | ||
| assert!(response.starts_with("HTTP/1.1 403 Forbidden\r\n")); | ||
| let (_, body) = response | ||
| .split_once("\r\n\r\n") | ||
| .expect("denial response should contain a body"); | ||
| let body: serde_json::Value = | ||
| serde_json::from_str(body).expect("denial body should be JSON"); | ||
| assert_eq!(body["error"], "policy_denied"); | ||
| assert_eq!( | ||
| body["detail"], | ||
| crate::l7::jsonrpc::UNSUPPORTED_MCP_BATCH_REASON | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn forward_l7_hard_deny_reason_includes_jsonrpc_response_frames() { | ||
| let request_info = crate::l7::L7RequestInfo { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems to special-case UNSUPPORTED_MCP_BATCH_REASON, but I don't think this is a good pattern. I would prefer it if the jsonrpc_info was the only source of this info.