diff --git a/internal/outputvalidation/file_ref_test.go b/internal/outputvalidation/file_ref_test.go new file mode 100644 index 000000000..63c8d9ea9 --- /dev/null +++ b/internal/outputvalidation/file_ref_test.go @@ -0,0 +1,54 @@ +package outputvalidation + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// fileURL turns an absolute path into a file:// URL on every OS. +func fileURL(p string) string { + s := filepath.ToSlash(p) + if !strings.HasPrefix(s, "/") { + s = "/" + s + } + return "file://" + s +} + +// An output schema comes from the upstream server. A $ref to a file:// URL +// must not make the proxy open a local file and compile it into the schema. +func TestCompile_RefusesFileRef(t *testing.T) { + local := filepath.Join(t.TempDir(), "local.json") + if err := os.WriteFile(local, []byte(`{"type":"string"}`), 0o600); err != nil { + t.Fatal(err) + } + ref, _ := json.Marshal(map[string]string{"$ref": fileURL(local)}) + + if entry := compile(string(ref)); !entry.sentinel { + t.Fatalf("schema with $ref %s compiled; the local file was loaded", fileURL(local)) + } + + v := New(0, 0, nil) + if verdict := v.Validate("srv:tool", string(ref), map[string]any{"id": 1}); verdict.IsViolation() { + t.Fatalf("verdict was driven by the local file's contents: %s", verdict.Reason) + } +} + +func TestCompile_LocalRefsAndMetaSchemaStillWork(t *testing.T) { + schema := `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": {"id": {"type": "integer"}}, + "type": "object", + "properties": {"id": {"$ref": "#/$defs/id"}} + }` + if entry := compile(schema); entry.sentinel { + t.Fatal("schema with an in-document $ref and the standard $schema failed to compile") + } + + v := New(0, 0, nil) + if verdict := v.Validate("srv:tool", schema, map[string]any{"id": "x"}); !verdict.IsViolation() { + t.Fatal("expected a violation for a string id") + } +} diff --git a/internal/outputvalidation/validator.go b/internal/outputvalidation/validator.go index c59e908c2..4a578b81d 100644 --- a/internal/outputvalidation/validator.go +++ b/internal/outputvalidation/validator.go @@ -208,6 +208,9 @@ func compile(schemaJSON string) *cacheEntry { // validation error messages (which are surfaced in audit records). const schemaURI = "mem://outputschema/schema" c := jsonschema.NewCompiler() + // The schema is upstream-controlled: an empty loader stops a $ref from + // reading local files (the default loader serves file:// URLs). + c.UseLoader(jsonschema.SchemeURLLoader{}) if err := c.AddResource(schemaURI, doc); err != nil { return &cacheEntry{sentinel: true} } diff --git a/internal/server/mcp_input_validation.go b/internal/server/mcp_input_validation.go index 96204ef3f..ee95a2f31 100644 --- a/internal/server/mcp_input_validation.go +++ b/internal/server/mcp_input_validation.go @@ -141,6 +141,9 @@ func compileInputSchema(paramsJSON string) *inputSchemaEntry { return &inputSchemaEntry{sentinel: true} } c := jsonschema.NewCompiler() + // The schema is upstream-controlled: an empty loader stops a $ref from + // reading local files (the default loader serves file:// URLs). + c.UseLoader(jsonschema.SchemeURLLoader{}) if err := c.AddResource(inputSchemaURI, doc); err != nil { return &inputSchemaEntry{sentinel: true} } diff --git a/internal/server/mcp_input_validation_file_ref_test.go b/internal/server/mcp_input_validation_file_ref_test.go new file mode 100644 index 000000000..abfb89b3b --- /dev/null +++ b/internal/server/mcp_input_validation_file_ref_test.go @@ -0,0 +1,34 @@ +package server + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// A tool's input schema comes from the upstream server. A $ref to a file:// +// URL must not make the proxy open a local file and compile it into the schema. +func TestCompileInputSchema_RefusesFileRef(t *testing.T) { + local := filepath.Join(t.TempDir(), "local.json") + if err := os.WriteFile(local, []byte(`{"type":"string"}`), 0o600); err != nil { + t.Fatal(err) + } + u := filepath.ToSlash(local) + if !strings.HasPrefix(u, "/") { + u = "/" + u + } + ref, _ := json.Marshal(map[string]string{"$ref": "file://" + u}) + + if entry := compileInputSchema(string(ref)); !entry.sentinel { + t.Fatalf("input schema with $ref file://%s compiled; the local file was loaded", u) + } +} + +func TestCompileInputSchema_LocalRefStillWorks(t *testing.T) { + schema := `{"$defs":{"q":{"type":"string"}},"type":"object","properties":{"q":{"$ref":"#/$defs/q"}}}` + if entry := compileInputSchema(schema); entry.sentinel { + t.Fatal("input schema with an in-document $ref failed to compile") + } +}