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`.
This commit is contained in:
Maciej Kisiel 2026-02-18 16:38:36 +01:00 • committed by Maciek Kisiel
commit 6e8ca568e4
No known key found for this signature in database
4 changed files with 127 additions and 20 deletions

19
internal/json/json.go Normal file
View file

@ -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)
}

View file

@ -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)
}
})
}
}

View file

@ -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.

View file

@ -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 {