diff --git a/pkg/chart/common/util/jsonschema.go b/pkg/chart/common/util/jsonschema.go index 63ca0c274..2dea57230 100644 --- a/pkg/chart/common/util/jsonschema.go +++ b/pkg/chart/common/util/jsonschema.go @@ -136,11 +136,14 @@ func ValidateAgainstSingleSchema(values common.Values, schemaJSON []byte) (reter } slog.Debug("unmarshalled JSON schema", "schema", schemaJSON) - // Configure compiler with loaders for different URL schemes + // Refuse external schema references (http(s):// and file://) instead of + // fetching them; see denyURLLoader. The urn scheme stays resolvable via the + // pluggable URNResolver for back-compatibility. + deny := denyURLLoader{} loader := jsonschema.SchemeURLLoader{ - "file": jsonschema.FileLoader{}, - "http": newHTTPURLLoader(), - "https": newHTTPURLLoader(), + "file": deny, + "http": deny, + "https": deny, "urn": urnLoader{}, } @@ -164,6 +167,28 @@ func ValidateAgainstSingleSchema(values common.Values, schemaJSON []byte) (reter return nil } +// denyURLLoader is a [jsonschema.URLLoader] that refuses to resolve any +// external schema reference. +// +// A chart's values.schema.json is untrusted input (loaded verbatim from a chart +// archive that may originate from a remote repository or OCI registry). If the +// JSON Schema compiler is allowed to follow external "$ref"/"$id"/"$schema" +// references, a malicious chart can drive Helm into fetching attacker-chosen +// URLs while validating values: "http(s)://" references become a server-side +// request forgery primitive (e.g. against cloud-metadata endpoints) and +// "file://" references read arbitrary local files into the schema graph - the +// JSON Schema analogue of an XXE attack. +// +// Standard meta-schemas (the json-schema.org drafts named by "$schema") are +// served by the compiler from an embedded copy and never reach a loader, so +// failing closed here keeps schema validation a purely local, in-archive +// operation without breaking legitimate charts. +type denyURLLoader struct{} + +func (denyURLLoader) Load(url string) (any, error) { + return nil, fmt.Errorf("loading external schema reference %q is not allowed", url) +} + // URNResolverFunc allows SDK to plug a URN resolver. It must return a // schema document compatible with the validator (e.g., result of // jsonschema.UnmarshalJSON). diff --git a/pkg/chart/common/util/jsonschema_test.go b/pkg/chart/common/util/jsonschema_test.go index 838d152a1..b980221ea 100644 --- a/pkg/chart/common/util/jsonschema_test.go +++ b/pkg/chart/common/util/jsonschema_test.go @@ -17,10 +17,13 @@ limitations under the License. package util import ( + "fmt" "net/http" "net/http/httptest" "os" + "path/filepath" "strings" + "sync/atomic" "testing" "helm.sh/helm/v4/pkg/chart/common" @@ -300,6 +303,46 @@ func TestValidateAgainstSingleSchema_UnresolvedURN_Ignored(t *testing.T) { } } +// TestValidateAgainstSingleSchema_ExternalRefDenied ensures that external schema +// references in an (untrusted) chart schema are refused rather than resolved, +// closing the SSRF / arbitrary-local-file-read vector. See denyURLLoader. +func TestValidateAgainstSingleSchema_ExternalRefDenied(t *testing.T) { + // An http:// $ref must fail closed and must not trigger an outbound request. + t.Run("http ref is not fetched (SSRF)", func(t *testing.T) { + var hits int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + w.Write([]byte(`{"type": "object"}`)) + })) + defer server.Close() + + schema := []byte(fmt.Sprintf(`{"$ref": %q}`, server.URL+"/evil.json")) + err := ValidateAgainstSingleSchema(common.Values{"any": "value"}, schema) + if err == nil { + t.Fatal("expected validation to fail closed on an external http $ref") + } + if got := atomic.LoadInt32(&hits); got != 0 { + t.Fatalf("external $ref was fetched: server was contacted %d time(s)", got) + } + }) + + // A file:// $ref must fail closed and must not read local file contents. + t.Run("file ref is not read", func(t *testing.T) { + secret := filepath.Join(t.TempDir(), "secret.txt") + if err := os.WriteFile(secret, []byte("TOP-SECRET"), 0o600); err != nil { + t.Fatal(err) + } + schema := []byte(fmt.Sprintf(`{"$ref": %q}`, "file:///"+filepath.ToSlash(secret))) + err := ValidateAgainstSingleSchema(common.Values{"any": "value"}, schema) + if err == nil { + t.Fatal("expected validation to fail closed on an external file $ref") + } + if strings.Contains(err.Error(), "TOP-SECRET") { + t.Fatalf("local file content leaked through schema validation: %v", err) + } + }) +} + // Non-regression tests for https://github.com/helm/helm/issues/31202 // Ensure ValidateAgainstSchema does not panic when: // - subchart key is missing