Skip to content

Commit fa7691f

Browse files
committed
fix: reject mangled publisher metadata
1 parent f0f03c0 commit fa7691f

5 files changed

Lines changed: 190 additions & 1 deletion

File tree

cmd/publisher/commands/publish.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ func PublishCommand(args []string) error {
2828
}
2929
return fmt.Errorf("failed to read server.json: %w", err)
3030
}
31+
if err := validateJSONUnicode(serverFile, serverData); err != nil {
32+
return err
33+
}
3134

3235
// Validate JSON
3336
var serverJSON apiv0.ServerJSON
@@ -99,6 +102,10 @@ func PublishCommand(args []string) error {
99102
}
100103

101104
func publishToRegistry(registryURL string, serverData []byte, token string) (*apiv0.ServerResponse, int, error) {
105+
if err := validateJSONUnicode("server.json", serverData); err != nil {
106+
return nil, 0, err
107+
}
108+
102109
// Parse the server JSON data
103110
var serverJSON apiv0.ServerJSON
104111
err := json.Unmarshal(serverData, &serverJSON)

cmd/publisher/commands/publish_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"encoding/json"
55
"io"
66
"net/http"
7+
"os"
8+
"path/filepath"
79
"testing"
810

911
"github.com/modelcontextprotocol/registry/cmd/publisher/commands"
@@ -37,6 +39,57 @@ func TestPublishCommand_Success(t *testing.T) {
3739
assert.NoError(t, err)
3840
}
3941

42+
func TestPublishCommand_PreservesNonASCIIDescription(t *testing.T) {
43+
server := SetupMockRegistryServer(t,
44+
func(w http.ResponseWriter, r *http.Request) {
45+
var req apiv0.ServerJSON
46+
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
47+
assert.Equal(t, "Tools for agents — safely", req.Description)
48+
49+
w.WriteHeader(http.StatusCreated)
50+
_ = json.NewEncoder(w).Encode(apiv0.ServerResponse{
51+
Server: apiv0.ServerJSON{
52+
Name: req.Name,
53+
Version: req.Version,
54+
},
55+
})
56+
},
57+
nil,
58+
)
59+
SetupTestToken(t, server.URL, "test-token")
60+
61+
CreateTestServerJSON(t, apiv0.ServerJSON{
62+
Schema: model.CurrentSchemaURL,
63+
Name: "com.example/test-server",
64+
Description: "Tools for agents — safely",
65+
Version: "1.0.0",
66+
})
67+
68+
require.NoError(t, commands.PublishCommand([]string{}))
69+
}
70+
71+
func TestPublishCommand_RejectsInvalidUTF8ServerJSON(t *testing.T) {
72+
createRawServerJSON(t, []byte{
73+
'{', '"', '$', 's', 'c', 'h', 'e', 'm', 'a', '"', ':', '"', '"', ',',
74+
'"', 'n', 'a', 'm', 'e', '"', ':', '"', 'c', 'o', 'm', '.', 'e', 'x', 'a', 'm', 'p', 'l', 'e', '/', 't', 'e', 's', 't', '"', ',',
75+
'"', 'd', 'e', 's', 'c', 'r', 'i', 'p', 't', 'i', 'o', 'n', '"', ':', '"', 'b', 'a', 'd', ' ', 0xe2, '"', ',',
76+
'"', 'v', 'e', 'r', 's', 'i', 'o', 'n', '"', ':', '"', '1', '.', '0', '.', '0', '"', '}',
77+
})
78+
79+
err := commands.PublishCommand([]string{})
80+
require.Error(t, err)
81+
assert.Contains(t, err.Error(), "UTF-8")
82+
}
83+
84+
func TestPublishCommand_RejectsUnpairedSurrogateEscape(t *testing.T) {
85+
createRawServerJSON(t, []byte(`{"$schema":"","name":"com.example/test","description":"bad \udc94","version":"1.0.0"}`))
86+
87+
err := commands.PublishCommand([]string{})
88+
require.Error(t, err)
89+
assert.Contains(t, err.Error(), "unpaired UTF-16 surrogate")
90+
assert.Contains(t, err.Error(), "$.description")
91+
}
92+
4093
func TestPublishCommand_422ValidationFlow(t *testing.T) {
4194
validateCallCount := 0
4295
publishCallCount := 0
@@ -102,6 +155,20 @@ func TestPublishCommand_422ValidationFlow(t *testing.T) {
102155
assert.Equal(t, 1, validateCallCount, "validate endpoint should be called once after 422")
103156
}
104157

158+
func createRawServerJSON(t *testing.T, data []byte) {
159+
t.Helper()
160+
161+
tempDir := t.TempDir()
162+
serverFile := filepath.Join(tempDir, "server.json")
163+
require.NoError(t, os.WriteFile(serverFile, data, 0600))
164+
165+
originalDir, err := os.Getwd()
166+
require.NoError(t, err)
167+
t.Cleanup(func() { _ = os.Chdir(originalDir) })
168+
169+
require.NoError(t, os.Chdir(tempDir))
170+
}
171+
105172
func TestPublishCommand_422WithMultipleIssues(t *testing.T) {
106173
validateCallCount := 0
107174

cmd/publisher/commands/testutil_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,10 @@ func SetupMockRegistryServer(t *testing.T, publishHandler func(w http.ResponseWr
6868
func SetupTestToken(t *testing.T, registryURL, token string) string {
6969
t.Helper()
7070

71-
// Override $HOME so tokenFilePath() resolves to a temp directory
71+
// Override the home directory so tokenFilePath() resolves to a temp directory.
7272
tempHome := t.TempDir()
7373
t.Setenv("HOME", tempHome)
74+
t.Setenv("USERPROFILE", tempHome)
7475

7576
dir := filepath.Join(tempHome, ".config", "mcp-publisher")
7677
require.NoError(t, os.MkdirAll(dir, 0700))

cmd/publisher/commands/unicode.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package commands
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"strconv"
8+
"unicode/utf8"
9+
)
10+
11+
func validateJSONUnicode(filename string, data []byte) error {
12+
if !utf8.Valid(data) {
13+
return fmt.Errorf("%s must be encoded as UTF-8", filename)
14+
}
15+
16+
if path, ok := findUnpairedSurrogate(data, "$"); ok {
17+
return fmt.Errorf("%s contains an unpaired UTF-16 surrogate escape at %s", filename, path)
18+
}
19+
20+
return nil
21+
}
22+
23+
func findUnpairedSurrogate(raw json.RawMessage, path string) (string, bool) {
24+
raw = bytes.TrimSpace(raw)
25+
if len(raw) == 0 {
26+
return "", false
27+
}
28+
29+
switch raw[0] {
30+
case '"':
31+
if hasUnpairedSurrogateEscape(raw) {
32+
return path, true
33+
}
34+
case '{':
35+
var obj map[string]json.RawMessage
36+
if err := json.Unmarshal(raw, &obj); err != nil {
37+
return "", false
38+
}
39+
for key, value := range obj {
40+
if badPath, ok := findUnpairedSurrogate(value, path+"."+key); ok {
41+
return badPath, true
42+
}
43+
}
44+
case '[':
45+
var items []json.RawMessage
46+
if err := json.Unmarshal(raw, &items); err != nil {
47+
return "", false
48+
}
49+
for i, item := range items {
50+
if badPath, ok := findUnpairedSurrogate(item, fmt.Sprintf("%s[%d]", path, i)); ok {
51+
return badPath, true
52+
}
53+
}
54+
}
55+
56+
return "", false
57+
}
58+
59+
func hasUnpairedSurrogateEscape(s []byte) bool {
60+
for i := 1; i < len(s)-1; i++ {
61+
if s[i] != '\\' || i+5 >= len(s) || s[i+1] != 'u' {
62+
continue
63+
}
64+
65+
code, ok := parseHex4(s[i+2 : i+6])
66+
if !ok {
67+
continue
68+
}
69+
70+
if isLowSurrogate(code) {
71+
return true
72+
}
73+
74+
if !isHighSurrogate(code) {
75+
i += 5
76+
continue
77+
}
78+
79+
if i+11 >= len(s) || s[i+6] != '\\' || s[i+7] != 'u' {
80+
return true
81+
}
82+
83+
next, ok := parseHex4(s[i+8 : i+12])
84+
if !ok || !isLowSurrogate(next) {
85+
return true
86+
}
87+
i += 11
88+
}
89+
90+
return false
91+
}
92+
93+
func parseHex4(b []byte) (rune, bool) {
94+
v, err := strconv.ParseInt(string(b), 16, 32)
95+
if err != nil {
96+
return 0, false
97+
}
98+
return rune(v), true
99+
}
100+
101+
func isHighSurrogate(r rune) bool {
102+
return r >= 0xD800 && r <= 0xDBFF
103+
}
104+
105+
func isLowSurrogate(r rune) bool {
106+
return r >= 0xDC00 && r <= 0xDFFF
107+
}

cmd/publisher/commands/validate.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ func ValidateCommand(args []string) error {
142142
}
143143
return fmt.Errorf("failed to read %s: %w", serverFile, err)
144144
}
145+
if err := validateJSONUnicode(serverFile, serverData); err != nil {
146+
return err
147+
}
145148

146149
// Validate JSON
147150
var serverJSON apiv0.ServerJSON
@@ -188,6 +191,10 @@ func ValidateCommand(args []string) error {
188191

189192
// validateViaAPI calls the /validate endpoint on the registry
190193
func validateViaAPI(registryURL string, serverData []byte) (*validators.ValidationResult, error) {
194+
if err := validateJSONUnicode("server.json", serverData); err != nil {
195+
return nil, err
196+
}
197+
191198
// Parse the server JSON data to ensure it's valid JSON
192199
var serverJSON apiv0.ServerJSON
193200
err := json.Unmarshal(serverData, &serverJSON)

0 commit comments

Comments
 (0)