Low-privilege MCP consumer bypasses Tool-Based Access Control via Content-Type case differential
Branch/Environment/Version
- Branch/Version: Master
- Environment: On-prem (Gateway); applies to environments MCP APIs are served
Describe the bug
The JSON-RPC/MCP security gate recognises a request as JSON-RPC only via a byte-exact strings.HasPrefix(contentType, "application/json") check and fails open when it does not match. HTTP media types are case-insensitive (RFC 9110 §8.3.1), but the bundled MCP execution engine (go-sdk) accepts them case-insensitively via mime.ParseMediaType. This parser differential means a request with Content-Type: Application/json (capital A) is not recognised by the gate — so no routing state is created and every downstream MCP access-control gate no-op's — while the engine still executes the request.
The result: an authenticated low-privilege MCP consumer bypasses per-key/per-policy Tool-Based Access Control (and, on the REST-as-MCP adapter, per-endpoint rate limits). They can invoke tools/resources/prompts they were explicitly denied and enumerate the full, unfiltered catalogue. Authentication still runs (the attacker uses their own valid key); only the MCP authorization layer is skipped.
The gate (case-sensitive) and the engine (case-insensitive) disagree on a single byte, and the ACL is skipped while the tool still runs — defeating the exact guarantee the feature exists to provide.
Reproduction steps Steps to reproduce the behavior:
Add an MCP API (REST-as-MCP synthetic adapter, or a generic MCP proxy to an RFC-compliant upstream).
Create a key/policy that denies a specific tool (e.g. block tool
ordersviamcp_access_rights).Send a
tools/callfor the denied tool with a lowercaseContent-Type: application/jsonand confirm it is correctly blocked (403, JSON-RPC "not available").Send the same request but change the header to
Content-Type: Application/json(capitalA):POST /<mcp-listen-path>/ HTTP/1.1 Authorization: <attacker's valid low-privilege key> Content-Type: Application/json Accept: application/json, text/event-stream {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"orders","arguments":{}}}Observe the denied tool executes (
200) instead of being blocked.{"method":"tools/list"}with the same case variant returns the unfiltered catalogue;resources/read(blocked URIs) andprompts/getbehave identically.
Actual behavior
With Content-Type: Application/json (capital A), the request bypasses MCP authorization: no routing state is created, the ACL/rate-limit gates no-op, and the engine executes the denied tool (returns 200 with tool output). The catalogue is returned unfiltered.
Expected behavior
Content-Type recognition should be case-insensitive, matching the execution engine and RFC 9110 §8.3.1. The case-variant request must be recognised as JSON-RPC and subjected to the same access control as the lowercase form — the denied tool returns 403 ("tool 'orders' is not available") and never reaches the engine.
Screenshots/Video N/A
Logs (debug mode or log file):
N/A — behaviour is deterministic from the request above; the denied tool returns a normal 200 MCP result instead of a 403 JSON-RPC access-denied response.
Configuration (tyk config file): Not configuration-specific — reproduces on any MCP API with a key/policy that denies a tool/resource/prompt.
Additional context
- Root cause is a parser differential between the security gate and the execution engine:
- Gate (case-sensitive, fails open):
gateway/mw_jsonrpc.govalidateJSONRPCRequestandgateway/mw_jsonrpc_rest_as_mcp_policy.goparseSyntheticAdapterJSONRPC. - Downstream gates that no-op on missing routing state:
mw_jsonrpc_access_control.go(method ACL),mw_mcp_access_control.go(per-primitive TBAC),res_handler_mcp_list_filter.go(tools/listfiltering). - Engine (case-insensitive): go-sdk
mcp/streamable.goviabaseMediaType→mime.ParseMediaType, which lower-cases the media type.
- Gate (case-sensitive, fails open):
Source: TykTechnologies/tyk