From 6e8ca568e4f259fa33445e8a637057751cb26787 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Wed, 18 Feb 2026 16:38:36 +0100 Subject: [PATCH] all: use case-sensitive JSON unmarshaling (#807) As reported in https://github.com/modelcontextprotocol/go-sdk/issues/805, case insensitive unmarshaling can pose security issues. This change adjusts all non-test and non-example unmarshaling to use a case sensitive mode. This introduces a new external dependency `github.com/segmentio/encoding`. --- go.mod | 6 ++++ go.sum | 6 ++++ internal/json/json.go | 19 +++++++++++ internal/json/json_test.go | 63 +++++++++++++++++++++++++++++++++++ internal/jsonrpc2/conn.go | 3 +- internal/jsonrpc2/messages.go | 4 ++- mcp/client.go | 3 +- mcp/protocol.go | 12 ++++--- mcp/server.go | 5 +-- mcp/shared.go | 3 +- mcp/streamable.go | 3 +- mcp/tool.go | 3 +- mcp/transport.go | 5 +-- mcp/util.go | 4 ++- oauthex/dcr.go | 8 +++-- 15 files changed, 127 insertions(+), 20 deletions(-) create mode 100644 internal/json/json.go create mode 100644 internal/json/json_test.go diff --git a/go.mod b/go.mod index 09b1be7..a5da3ba 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,13 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.4.2 + github.com/segmentio/encoding v0.5.3 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/oauth2 v0.30.0 golang.org/x/tools v0.34.0 ) + +require ( + github.com/segmentio/asm v1.1.3 // indirect + golang.org/x/sys v0.35.0 // indirect +) diff --git a/go.sum b/go.sum index dc70202..aba85ad 100644 --- a/go.sum +++ b/go.sum @@ -4,9 +4,15 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/internal/json/json.go b/internal/json/json.go new file mode 100644 index 0000000..f06609c --- /dev/null +++ b/internal/json/json.go @@ -0,0 +1,19 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// Package json provides internal JSON utilities. + +package json + +import ( + "bytes" + + "github.com/segmentio/encoding/json" +) + +func Unmarshal(data []byte, v any) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DontMatchCaseInsensitiveStructFields() + return dec.Decode(v) +} diff --git a/internal/json/json_test.go b/internal/json/json_test.go new file mode 100644 index 0000000..cc79b64 --- /dev/null +++ b/internal/json/json_test.go @@ -0,0 +1,63 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package json + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestUnmarshalCaseSensitivity(t *testing.T) { + type Nested struct { + Field string `json:"field"` + } + type Target struct { + Field string + TaggedField string `json:"custom_tag"` + Nested *Nested + } + + tests := []struct { + name string + json string + want Target + }{ + { + name: "exact match", + json: `{"Field": "value", "custom_tag": "tagged", "Nested": {"field": "nested"}}`, + want: Target{ + Field: "value", + TaggedField: "tagged", + Nested: &Nested{ + Field: "nested", + }, + }, + }, + { + name: "case mismatch", + json: `{"field": "value", "Custom_tag": "tagged", "Nested": {"Field": "nested"}}`, + want: Target{ + Field: "", + TaggedField: "", + Nested: &Nested{ + Field: "", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got Target + if err := Unmarshal([]byte(tt.json), &got); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("Unmarshal mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 627ffe7..571df63 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -6,13 +6,14 @@ package jsonrpc2 import ( "context" - "encoding/json" "errors" "fmt" "io" "sync" "sync/atomic" "time" + + "github.com/modelcontextprotocol/go-sdk/internal/json" ) // Binder builds a connection configuration. diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index 791e698..ae5b640 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -8,6 +8,8 @@ import ( "encoding/json" "errors" "fmt" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) // ID is a Request identifier, which is defined by the spec to be a string, integer, or null. @@ -167,7 +169,7 @@ func EncodeIndent(msg Message, prefix, indent string) ([]byte, error) { func DecodeMessage(data []byte) (Message, error) { msg := wireCombined{} - if err := json.Unmarshal(data, &msg); err != nil { + if err := internaljson.Unmarshal(data, &msg); err != nil { return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err) } if msg.VersionTag != wireVersion { diff --git a/mcp/client.go b/mcp/client.go index 57c30fb..63ffa0a 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -6,7 +6,6 @@ package mcp import ( "context" - "encoding/json" "errors" "fmt" "iter" @@ -18,7 +17,7 @@ import ( "time" "github.com/google/jsonschema-go/jsonschema" - + "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) diff --git a/mcp/protocol.go b/mcp/protocol.go index bea776f..0e07d67 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -13,6 +13,8 @@ package mcp import ( "encoding/json" "fmt" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) // Optional annotations for the client. The client can use annotations to inform @@ -140,7 +142,7 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error { res Content []*wireContent `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -286,7 +288,7 @@ type CompleteReference struct { func (r *CompleteReference) UnmarshalJSON(data []byte) error { type wireCompleteReference CompleteReference // for naive unmarshaling var r2 wireCompleteReference - if err := json.Unmarshal(data, &r2); err != nil { + if err := internaljson.Unmarshal(data, &r2); err != nil { return err } switch r2.Type { @@ -402,7 +404,7 @@ func (r *CreateMessageResult) UnmarshalJSON(data []byte) error { result Content *wireContent `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -838,7 +840,7 @@ func (m *PromptMessage) UnmarshalJSON(data []byte) error { msg Content *wireContent `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -1014,7 +1016,7 @@ func (m *SamplingMessage) UnmarshalJSON(data []byte) error { msg Content *wireContent `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error diff --git a/mcp/server.go b/mcp/server.go index 687e799..cbed5b1 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -24,6 +24,7 @@ import ( "time" "github.com/google/jsonschema-go/jsonschema" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -326,7 +327,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa // Unmarshal and validate args. var in In if input != nil { - if err := json.Unmarshal(input, &in); err != nil { + if err := internaljson.Unmarshal(input, &in); err != nil { return nil, fmt.Errorf("%w: %v", jsonrpc2.ErrInvalidParams, err) } } @@ -1325,7 +1326,7 @@ func initializeMethodInfo() methodInfo { info.unmarshalParams = func(m json.RawMessage) (Params, error) { var params *initializeParamsV2 if m != nil { - if err := json.Unmarshal(m, ¶ms); err != nil { + if err := internaljson.Unmarshal(m, ¶ms); err != nil { return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, params, err) } } diff --git a/mcp/shared.go b/mcp/shared.go index d83eae7..bda00c2 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -23,6 +23,7 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/auth" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -283,7 +284,7 @@ func newMethodInfo[P paramsPtr[T], R Result, T any](flags methodFlags) methodInf unmarshalParams: func(m json.RawMessage) (Params, error) { var p P if m != nil { - if err := json.Unmarshal(m, &p); err != nil { + if err := internaljson.Unmarshal(m, &p); err != nil { return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err) } } diff --git a/mcp/streamable.go b/mcp/streamable.go index f2a2895..1fdf973 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -28,6 +28,7 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/auth" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/xcontext" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -1074,7 +1075,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques isInitialize = true // Extract the protocol version from InitializeParams. var params InitializeParams - if err := json.Unmarshal(jreq.Params, ¶ms); err == nil { + if err := internaljson.Unmarshal(jreq.Params, ¶ms); err == nil { initializeProtocolVersion = params.ProtocolVersion } } diff --git a/mcp/tool.go b/mcp/tool.go index 8aa7c3c..3ecb59d 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/google/jsonschema-go/jsonschema" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) // A ToolHandler handles a call to tools/call. @@ -83,7 +84,7 @@ func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawM if resolved != nil { v := make(map[string]any) if len(data) > 0 { - if err := json.Unmarshal(data, &v); err != nil { + if err := internaljson.Unmarshal(data, &v); err != nil { return nil, fmt.Errorf("unmarshaling arguments: %w", err) } } diff --git a/mcp/transport.go b/mcp/transport.go index 25f1d5d..585df87 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -15,6 +15,7 @@ import ( "os" "sync" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/xcontext" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -189,7 +190,7 @@ type canceller struct { func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result any, err error) { if req.Method == notificationCancelled { var params CancelledParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil { + if err := internaljson.Unmarshal(req.Params, ¶ms); err != nil { return nil, err } id, err := jsonrpc2.MakeID(params.RequestID) @@ -565,7 +566,7 @@ func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) { func readBatch(data []byte) (msgs []jsonrpc.Message, isBatch bool, _ error) { // Try to read an array of messages first. var rawBatch []json.RawMessage - if err := json.Unmarshal(data, &rawBatch); err == nil { + if err := internaljson.Unmarshal(data, &rawBatch); err == nil { if len(rawBatch) == 0 { return nil, true, fmt.Errorf("empty batch") } diff --git a/mcp/util.go b/mcp/util.go index 5ada466..be3f3c7 100644 --- a/mcp/util.go +++ b/mcp/util.go @@ -7,6 +7,8 @@ package mcp import ( "crypto/rand" "encoding/json" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) func assert(cond bool, msg string) { @@ -36,7 +38,7 @@ func remarshal(from, to any) error { if err != nil { return err } - if err := json.Unmarshal(data, to); err != nil { + if err := internaljson.Unmarshal(data, to); err != nil { return err } return nil diff --git a/oauthex/dcr.go b/oauthex/dcr.go index c64cb8c..6db3025 100644 --- a/oauthex/dcr.go +++ b/oauthex/dcr.go @@ -17,6 +17,8 @@ import ( "io" "net/http" "time" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) // ClientRegistrationMetadata represents the client metadata fields for the DCR POST request (RFC 7591). @@ -144,7 +146,7 @@ func (r *ClientRegistrationResponse) UnmarshalJSON(data []byte) error { }{ alias: (*alias)(r), } - if err := json.Unmarshal(data, &aux); err != nil { + if err := internaljson.Unmarshal(data, &aux); err != nil { return err } if aux.ClientIDIssuedAt != 0 { @@ -206,7 +208,7 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta if resp.StatusCode == http.StatusCreated { var regResponse ClientRegistrationResponse - if err := json.Unmarshal(body, ®Response); err != nil { + if err := internaljson.Unmarshal(body, ®Response); err != nil { return nil, fmt.Errorf("failed to decode successful registration response: %w (%s)", err, string(body)) } if regResponse.ClientID == "" { @@ -221,7 +223,7 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta if resp.StatusCode == http.StatusBadRequest { var regError ClientRegistrationError - if err := json.Unmarshal(body, ®Error); err != nil { + if err := internaljson.Unmarshal(body, ®Error); err != nil { return nil, fmt.Errorf("failed to decode registration error response: %w (%s)", err, string(body)) } return nil, ®Error