Skip to content

Commit 0bebdf4

Browse files
authored
fix(schema): stop upstream tool schemas from loading local files (#1788)
## Bug Two validators compile the JSON Schema that an upstream server declares for its tools: - `internal/outputvalidation/validator.go` `compile()`: the `outputSchema`, used for output-schema validation (Spec 056; on by default in `warn` mode). - `internal/server/mcp_input_validation.go` `compileInputSchema()`: the `inputSchema`, used for pre-dispatch argument validation (Spec 085; always on). Both call `jsonschema.NewCompiler()` without setting a loader. In `santhosh-tekuri/jsonschema/v6` the default loader is `FileLoader`, which opens any `file://` URL. So when a schema contains `{"$ref": "file:///any/path.json"}`, mcpproxy opens that path on the user's machine, parses it, and compiles it into the tool's schema. The verdict then depends on a local file the upstream chose. ## Trigger Any upstream, including a remote HTTP one, can list a tool whose `inputSchema` or `outputSchema` contains a `file://` `$ref`. The file is read on the first call to that tool. No user action is needed beyond calling the tool. ## Fix Call `c.UseLoader(jsonschema.SchemeURLLoader{})` on both compilers. The empty loader rejects every external URL scheme. Two things keep working: - In-document refs (`#/$defs/...`) resolve against the in-memory resource and never reach the loader. - The standard `$schema` metaschemas are embedded in the library and are served before the loader is consulted. A schema that uses an external ref now fails to compile and takes the existing fail-open path (FR-A9 / FR-013b). ## Tests - `TestCompile_RefusesFileRef` (outputvalidation) and `TestCompileInputSchema_RefusesFileRef` (server): a temp file holds `{"type":"string"}`, and the schema is `{"$ref":"file://<that file>"}`. With the fix it must not compile. - `TestCompile_LocalRefsAndMetaSchemaStillWork` and `TestCompileInputSchema_LocalRefStillWorks`: a `#/$defs` ref and the draft 2020-12 `$schema` still compile and validate. ## Commands run - `CGO_ENABLED=0 go test -count=1 -run 'Test' ./internal/outputvalidation/`: ok - `CGO_ENABLED=0 go test -count=1 -run 'TestCompileInputSchema_|TestPreDispatchValidation_' ./internal/server/`: ok - `CGO_ENABLED=0 go vet ./internal/outputvalidation/`: ok - On current `main` (and again with both one-line fixes reverted), both `RefusesFileRef` tests fail, with `schema with $ref file:///…/local.json compiled; the local file was loaded`. I did not run the full `./internal/server` package locally.
1 parent e8cf15a commit 0bebdf4

4 files changed

Lines changed: 94 additions & 0 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package outputvalidation
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
// fileURL turns an absolute path into a file:// URL on every OS.
12+
func fileURL(p string) string {
13+
s := filepath.ToSlash(p)
14+
if !strings.HasPrefix(s, "/") {
15+
s = "/" + s
16+
}
17+
return "file://" + s
18+
}
19+
20+
// An output schema comes from the upstream server. A $ref to a file:// URL
21+
// must not make the proxy open a local file and compile it into the schema.
22+
func TestCompile_RefusesFileRef(t *testing.T) {
23+
local := filepath.Join(t.TempDir(), "local.json")
24+
if err := os.WriteFile(local, []byte(`{"type":"string"}`), 0o600); err != nil {
25+
t.Fatal(err)
26+
}
27+
ref, _ := json.Marshal(map[string]string{"$ref": fileURL(local)})
28+
29+
if entry := compile(string(ref)); !entry.sentinel {
30+
t.Fatalf("schema with $ref %s compiled; the local file was loaded", fileURL(local))
31+
}
32+
33+
v := New(0, 0, nil)
34+
if verdict := v.Validate("srv:tool", string(ref), map[string]any{"id": 1}); verdict.IsViolation() {
35+
t.Fatalf("verdict was driven by the local file's contents: %s", verdict.Reason)
36+
}
37+
}
38+
39+
func TestCompile_LocalRefsAndMetaSchemaStillWork(t *testing.T) {
40+
schema := `{
41+
"$schema": "https://json-schema.org/draft/2020-12/schema",
42+
"$defs": {"id": {"type": "integer"}},
43+
"type": "object",
44+
"properties": {"id": {"$ref": "#/$defs/id"}}
45+
}`
46+
if entry := compile(schema); entry.sentinel {
47+
t.Fatal("schema with an in-document $ref and the standard $schema failed to compile")
48+
}
49+
50+
v := New(0, 0, nil)
51+
if verdict := v.Validate("srv:tool", schema, map[string]any{"id": "x"}); !verdict.IsViolation() {
52+
t.Fatal("expected a violation for a string id")
53+
}
54+
}

‎internal/outputvalidation/validator.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ func compile(schemaJSON string) *cacheEntry {
208208
// validation error messages (which are surfaced in audit records).
209209
const schemaURI = "mem://outputschema/schema"
210210
c := jsonschema.NewCompiler()
211+
// The schema is upstream-controlled: an empty loader stops a $ref from
212+
// reading local files (the default loader serves file:// URLs).
213+
c.UseLoader(jsonschema.SchemeURLLoader{})
211214
if err := c.AddResource(schemaURI, doc); err != nil {
212215
return &cacheEntry{sentinel: true}
213216
}

‎internal/server/mcp_input_validation.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ func compileInputSchema(paramsJSON string) *inputSchemaEntry {
141141
return &inputSchemaEntry{sentinel: true}
142142
}
143143
c := jsonschema.NewCompiler()
144+
// The schema is upstream-controlled: an empty loader stops a $ref from
145+
// reading local files (the default loader serves file:// URLs).
146+
c.UseLoader(jsonschema.SchemeURLLoader{})
144147
if err := c.AddResource(inputSchemaURI, doc); err != nil {
145148
return &inputSchemaEntry{sentinel: true}
146149
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package server
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
// A tool's input schema comes from the upstream server. A $ref to a file://
12+
// URL must not make the proxy open a local file and compile it into the schema.
13+
func TestCompileInputSchema_RefusesFileRef(t *testing.T) {
14+
local := filepath.Join(t.TempDir(), "local.json")
15+
if err := os.WriteFile(local, []byte(`{"type":"string"}`), 0o600); err != nil {
16+
t.Fatal(err)
17+
}
18+
u := filepath.ToSlash(local)
19+
if !strings.HasPrefix(u, "/") {
20+
u = "/" + u
21+
}
22+
ref, _ := json.Marshal(map[string]string{"$ref": "file://" + u})
23+
24+
if entry := compileInputSchema(string(ref)); !entry.sentinel {
25+
t.Fatalf("input schema with $ref file://%s compiled; the local file was loaded", u)
26+
}
27+
}
28+
29+
func TestCompileInputSchema_LocalRefStillWorks(t *testing.T) {
30+
schema := `{"$defs":{"q":{"type":"string"}},"type":"object","properties":{"q":{"$ref":"#/$defs/q"}}}`
31+
if entry := compileInputSchema(schema); entry.sentinel {
32+
t.Fatal("input schema with an in-document $ref failed to compile")
33+
}
34+
}

0 commit comments

Comments
 (0)