Add opt-in requestState sealing via RequestStateSecurity
## Motivation and Context The SEP-2322 `requestState` continuation string leaves the server, sits in the client's hands, and comes back as client-controlled input. Without protection a client can read the server's continuation state, tamper with it (for example forging an already-answered elicitation), or replay a state issued for one call against another tool, other arguments, or another server. The multi-round-trip documentation of the Python SDK is explicit that the echo must be treated as untrusted input; its high-level server seals the state by default via `RequestStateBoundary`. New `MCP::Server::RequestStateSecurity` (OpenSSL standard library only) brings that protection to this SDK as an opt-in: - `seal` encrypts the plaintext state with AES-256-GCM (clients cannot read it, not merely verify it) inside a claims envelope binding an expiry window (`ttl:`, default 300 seconds, re-sealed each round), the originating method and target (tool/prompt name or resource URI), a digest of the originating arguments (stringified and sorted recursively, so symbol/string parses of identical JSON digest identically), and an optional `audience:`. The token format is `v1.<base64url(iv || ciphertext || tag)>` with the version prefix bound as GCM associated data, following the Python SDK's `AESGCMRequestStateCodec`. - `unseal` verifies every claim fail-closed and raises `InvalidStateError` on tampering, expiry, or any mismatch. Passing an instance via `Server.new(request_state_security:)` makes the seal/unseal transparent: issuance seals the `requestState` of an outgoing `input_required` result, and dispatch unseals the echoed token for `tools/call`, `prompts/get`, and `resources/read` before any handler runs, so `server_context.request_state` always reads the plaintext the handler wrote. A tampered, expired, or cross-call echo is rejected as `-32602` with "Invalid or expired requestState", matching the Python SDK's frozen error. Without the option the state crosses the wire exactly as the handler wrote it (the Python low-level Server behavior); the README documents that this is then the handler author's responsibility, and that multi-process deployments must share the key across workers. The conformance fixture opts in with a random per-boot key, both to dogfood the feature and because the tampered-state scenario of the 2026-07-28 conformance requirements retries an MRTR request with a corrupted `requestState` and requires a JSON-RPC error; without integrity-checked state the fixture cannot detect the corruption. Every MRTR round trip completes within one server process, so no key persistence is needed. Sealing is transparent to the fixture tools, whose `requestState` plaintext round-trips unchanged, and the new `test_input_required_result_tampered_state` tool is therefore a plain MRTR elicitation flow; the tampered echo is rejected with `-32602` by the server-level unsealing before dispatch ever reaches it. Refs #382. ## How Has This Been Tested? New `test/mcp/server/request_state_security_test.rb` covers constructor validation, the seal/unseal round trip (opaque `v1.` token, plaintext not visible), expiry via time travel, fail-closed rejection of method/target/digest/audience mismatches, tampered and malformed tokens, and tokens sealed under a different key. New tests in `test/mcp/server_test.rb` drive `Server#handle` with the modern envelope: transparent sealing on issuance and plaintext restoration on the retry leg, `-32602` for tampered echoes and for echoes replayed against different arguments, and the pass-through default when `request_state_security:` is not set. Against the fixture server at `--spec-version 2026-07-28` (run with `@modelcontextprotocol/conformance@alpha`; the 2026-07-28 scenarios are not yet in a stable conformance release), `sep-2322-reject-tampered-state` reports SUCCESS, and the other 13 `input-required-result-*` scenarios stay green with sealing active, confirming that sealed states round-trip through the suite's retries without false rejections (the seal binds `method`, target, and an arguments digest, and the suite echoes all three unchanged). The `--requirements 2025-11-25` server leg still passes. ## Breaking Changes None. The feature is opt-in via a new keyword argument that defaults to `nil`, in which case behavior is byte-identical to before.
This commit is contained in:
parent
4fac357343
commit
e6b5a9b5e3
2 changed files with 417 additions and 6 deletions
90
test/mcp/server/request_state_security_test.rb
Normal file
90
test/mcp/server/request_state_security_test.rb
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
module MCP
|
||||
class Server
|
||||
class RequestStateSecurityTest < ActiveSupport::TestCase
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
KEY = ("k" * 32).freeze
|
||||
|
||||
test "validates constructor arguments" do
|
||||
assert_raises(ArgumentError) { RequestStateSecurity.new(key: "short") }
|
||||
assert_raises(ArgumentError) { RequestStateSecurity.new(key: KEY, ttl: 0) }
|
||||
end
|
||||
|
||||
test "seals and unseals a state bound to the originating request" do
|
||||
security = RequestStateSecurity.new(key: KEY, audience: "my_server")
|
||||
sealed = security.seal("plain-state", method: "tools/call", target: "my_tool", arguments_digest: "digest")
|
||||
|
||||
assert sealed.start_with?("v1.")
|
||||
refute_includes sealed, "plain-state"
|
||||
assert_equal "plain-state",
|
||||
security.unseal(sealed, method: "tools/call", target: "my_tool", arguments_digest: "digest")
|
||||
end
|
||||
|
||||
test "rejects expired tokens" do
|
||||
security = RequestStateSecurity.new(key: KEY, ttl: 60)
|
||||
sealed = security.seal("state", method: "tools/call", target: "t", arguments_digest: "d")
|
||||
|
||||
travel 120 do
|
||||
assert_raises(RequestStateSecurity::InvalidStateError) do
|
||||
security.unseal(sealed, method: "tools/call", target: "t", arguments_digest: "d")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects claims mismatches fail-closed" do
|
||||
security = RequestStateSecurity.new(key: KEY, audience: "my_server")
|
||||
sealed = security.seal("state", method: "tools/call", target: "my_tool", arguments_digest: "digest")
|
||||
|
||||
assert_raises(RequestStateSecurity::InvalidStateError) do
|
||||
security.unseal(sealed, method: "prompts/get", target: "my_tool", arguments_digest: "digest")
|
||||
end
|
||||
assert_raises(RequestStateSecurity::InvalidStateError) do
|
||||
security.unseal(sealed, method: "tools/call", target: "other_tool", arguments_digest: "digest")
|
||||
end
|
||||
assert_raises(RequestStateSecurity::InvalidStateError) do
|
||||
security.unseal(sealed, method: "tools/call", target: "my_tool", arguments_digest: "other")
|
||||
end
|
||||
|
||||
other_audience = RequestStateSecurity.new(key: KEY, audience: "other_server")
|
||||
assert_raises(RequestStateSecurity::InvalidStateError) do
|
||||
other_audience.unseal(sealed, method: "tools/call", target: "my_tool", arguments_digest: "digest")
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects tampered and malformed tokens" do
|
||||
security = RequestStateSecurity.new(key: KEY)
|
||||
sealed = security.seal("state", method: "tools/call", target: "t", arguments_digest: "d")
|
||||
|
||||
tampered = sealed.dup
|
||||
tampered[-1] = tampered[-1] == "A" ? "B" : "A"
|
||||
assert_raises(RequestStateSecurity::InvalidStateError) do
|
||||
security.unseal(tampered, method: "tools/call", target: "t", arguments_digest: "d")
|
||||
end
|
||||
|
||||
assert_raises(RequestStateSecurity::InvalidStateError) do
|
||||
security.unseal("not-a-token", method: "tools/call", target: "t", arguments_digest: "d")
|
||||
end
|
||||
assert_raises(RequestStateSecurity::InvalidStateError) do
|
||||
security.unseal("v1.!!!", method: "tools/call", target: "t", arguments_digest: "d")
|
||||
end
|
||||
assert_raises(RequestStateSecurity::InvalidStateError) do
|
||||
security.unseal(nil, method: "tools/call", target: "t", arguments_digest: "d")
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects tokens sealed under a different key" do
|
||||
sealed = RequestStateSecurity.new(key: KEY)
|
||||
.seal("state", method: "tools/call", target: "t", arguments_digest: "d")
|
||||
other = RequestStateSecurity.new(key: "x" * 32)
|
||||
|
||||
assert_raises(RequestStateSecurity::InvalidStateError) do
|
||||
other.unseal(sealed, method: "tools/call", target: "t", arguments_digest: "d")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -505,6 +505,80 @@ module MCP
|
|||
assert_equal "input_required", response.dig(:result, :resultType)
|
||||
end
|
||||
|
||||
test "#handle seals and unseals requestState transparently when request_state_security is set" do
|
||||
security = Server::RequestStateSecurity.new(key: "k" * 32)
|
||||
server = Server.new(name: "mrtr_test", tools: [], request_state_security: security)
|
||||
seen_state = nil
|
||||
server.define_tool(name: "mrtr_tool") do |server_context:|
|
||||
if server_context.request_state
|
||||
seen_state = server_context.request_state
|
||||
Tool::Response.new([{ type: "text", text: "done" }])
|
||||
else
|
||||
Server::InputRequiredResult.new(request_state: "plain-state")
|
||||
end
|
||||
end
|
||||
|
||||
first = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: {} }))
|
||||
sealed = first.dig(:result, :requestState)
|
||||
|
||||
assert sealed.start_with?("v1.")
|
||||
refute_equal "plain-state", sealed
|
||||
|
||||
retry_response = server.handle(modern_request("tools/call", {
|
||||
name: "mrtr_tool",
|
||||
arguments: {},
|
||||
requestState: sealed,
|
||||
}))
|
||||
|
||||
refute_nil retry_response[:result]
|
||||
# The handler reads the plaintext it originally wrote.
|
||||
assert_equal "plain-state", seen_state
|
||||
end
|
||||
|
||||
test "#handle rejects a tampered or cross-call requestState echo with -32602" do
|
||||
security = Server::RequestStateSecurity.new(key: "k" * 32)
|
||||
server = Server.new(name: "mrtr_test", tools: [], request_state_security: security)
|
||||
server.define_tool(name: "mrtr_tool") do |server_context:|
|
||||
if server_context.request_state
|
||||
Tool::Response.new([{ type: "text", text: "done" }])
|
||||
else
|
||||
Server::InputRequiredResult.new(request_state: "plain-state")
|
||||
end
|
||||
end
|
||||
|
||||
first = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: {} }))
|
||||
sealed = first.dig(:result, :requestState)
|
||||
|
||||
tampered = server.handle(modern_request("tools/call", {
|
||||
name: "mrtr_tool",
|
||||
arguments: {},
|
||||
requestState: sealed.sub("v1.", "v1.x"),
|
||||
}))
|
||||
# Different arguments than the sealing call: the digest claim no longer matches.
|
||||
cross_call = server.handle(modern_request("tools/call", {
|
||||
name: "mrtr_tool",
|
||||
arguments: { other: true },
|
||||
requestState: sealed,
|
||||
}))
|
||||
|
||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, tampered.dig(:error, :code)
|
||||
assert_equal "Invalid or expired requestState", tampered.dig(:error, :message)
|
||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, cross_call.dig(:error, :code)
|
||||
end
|
||||
|
||||
test "#handle passes requestState through untouched without request_state_security" do
|
||||
server = Server.new(name: "mrtr_test", tools: [])
|
||||
seen_state = nil
|
||||
server.define_tool(name: "mrtr_tool") do |server_context:|
|
||||
seen_state = server_context.request_state
|
||||
Tool::Response.new([{ type: "text", text: "done" }])
|
||||
end
|
||||
|
||||
server.handle(modern_request("tools/call", { name: "mrtr_tool", requestState: "raw-state" }))
|
||||
|
||||
assert_equal "raw-state", seen_state
|
||||
end
|
||||
|
||||
test "#handle prompts/get serializes an input_required result on a modern request" do
|
||||
server = Server.new(name: "mrtr_test")
|
||||
server.define_prompt(name: "mrtr_prompt", arguments: []) do |_args, server_context:|
|
||||
|
|
|
|||
Loading…
Reference in a new issue