From 113a8cc480b89683d5a28648a9e8ee8769b6659b Mon Sep 17 00:00:00 2001 From: Luigi Iacuaniello Date: Thu, 12 Nov 2026 17:42:10 +0100 Subject: [PATCH] mcp: reject unencoded non-ASCII characters in Mcp-Param headers validateParamHeaders compared an Mcp-Param-{Name} value without the =?base64?...?= wrapper directly with the body value, so a value with characters outside visible ASCII would pass whenever it matched the body argument. The 2026-07-28 specification requires such values to be Base64-encoded, and servers to reject a recognized Mcp-Param header that contains invalid characters. Reject a value containing any byte other than visible ASCII, space or horizontal tab before decoding it, with the usual header mismatch error -32020. Fixes #1520 --- mcp/streamable_headers.go | 16 ++++++++++++++ mcp/streamable_headers_test.go | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index 2207d784..890c47fe 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -567,6 +567,10 @@ func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool) return fmt.Errorf("header mismatch: missing %s header for parameter %q", fullHeader, strings.Join(b.Path, ".")) } + if !isHeaderSafe(headerVal) { + return fmt.Errorf("header mismatch: %s header contains invalid characters", fullHeader) + } + decoded, ok := decodeHeaderValue(headerVal) if !ok { return fmt.Errorf("header mismatch: %s header contains invalid Base64 encoding", fullHeader) @@ -669,6 +673,18 @@ func requiresBase64Encoding(s string) bool { return false } +// isHeaderSafe reports whether s holds only the characters an Mcp-Param-* +// value may carry unencoded: visible ASCII, space and horizontal tab +// (RFC 9110, section 5.5). Anything else must arrive Base64-wrapped. +func isHeaderSafe(s string) bool { + for i := 0; i < len(s); i++ { + if c := s[i]; c != '\t' && (c < 0x20 || c > 0x7e) { + return false + } + } + return true +} + func encodeBase64(s string) string { return base64Prefix + base64.StdEncoding.EncodeToString([]byte(s)) + base64Suffix } diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index 07c0c3d7..b51153ca 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -1780,6 +1780,46 @@ func TestValidateParamHeaders_EmptyString(t *testing.T) { } } +// TestValidateParamHeaders_InvalidCharacters checks that a value outside +// visible ASCII is rejected unless it is Base64-wrapped, even when it equals +// the body value. +func TestValidateParamHeaders_InvalidCharacters(t *testing.T) { + tool := &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + } + tests := []struct { + name string + header string + arg string + wantErr bool + }{ + {"raw non-ASCII", "café", "café", true}, + {"raw control character", "a\x7fb", "a\x7fb", true}, + {"Base64-wrapped non-ASCII", encodeBase64("café"), "café", false}, + {"visible ASCII, space and tab", "us west\t1", "us west\t1", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + header.Set(paramHeaderPrefix+"Region", tt.header) + msg := &jsonrpc.Request{ + Method: "tools/call", + Params: mustMarshal(&CallToolParams{Name: "test", Arguments: map[string]any{"region": tt.arg}}), + } + err := validateParamHeaders(header, msg, tool) + if gotErr := err != nil; gotErr != tt.wantErr { + t.Errorf("validateParamHeaders() = %v, want error: %t", err, tt.wantErr) + } + }) + } +} + // TestParamHeaderEmptyStringRoundTrip verifies that the server accepts the // present but empty Mcp-Param header the client sends for an empty string. func TestParamHeaderEmptyStringRoundTrip(t *testing.T) {