From 5582eb2681ab332cc1171be43823586b9bab6708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20BIDON?= Date: Tue, 20 Oct 2026 21:40:13 +0200 Subject: [PATCH 1/2] fix: do not redeem a pooled validator twice when Validate panics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With recycled validators, SchemaValidator.Validate and the allOf, anyOf, oneOf and not loops of schemaPropsValidator cleared the slot of a child only after the child's Validate returned. A child redeems itself in a deferred call, so when its Validate panicked (e.g. a $ref under a property that cannot be expanded), the parent's deferred redeemChildren redeemed it a second time. The pool then hands the same object to two later borrowers. A server survives the panic (net/http recovers handler panics), so later requests could share validator state and return wrong results. Clear the slot before calling Validate. TestPools_NoDoubleRedeemOnPanic reproduces it with -tags poolsdebug, which reports the double redeem. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frédéric BIDON --- pools_leak_test.go | 21 +++++++++++++++++++++ schema.go | 6 ++++-- schema_props.go | 17 +++++++++-------- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/pools_leak_test.go b/pools_leak_test.go index 4243d39..e97d0f1 100644 --- a/pools_leak_test.go +++ b/pools_leak_test.go @@ -79,3 +79,24 @@ func TestPools_NoLeakOnNilSchema(t *testing.T) { require.NoError(t, AgainstSchema(nil, map[string]any{}, strfmt.Default)) require.True(t, pools.AssertNoLeaks(t)) } + +// TestPools_NoDoubleRedeemOnPanic builds a validator that panics inside Validate: the $ref of +// property "x" is expanded when the object validator walks the data. The validators that +// redeem themselves on the way out must not be redeemed again by their parent. +// +// Like the other tests here, it only bites with the "poolsdebug" tag: the pools then panic +// on a double redeem, and that panic replaces the one from the schema. +func TestPools_NoDoubleRedeemOnPanic(t *testing.T) { + resetPools() + pools.ResetTracking() + t.Cleanup(resetPools) + t.Cleanup(pools.ResetTracking) + + var schema spec.Schema + require.NoError(t, json.Unmarshal([]byte(`{"properties":{"x":{"$ref":"#/definitions/missing"}}}`), &schema)) + + require.PanicsWithValue(t, + `Invalid schema provided to SchemaValidator: nil value has no field "missing": JSON pointer error`, + func() { _ = AgainstSchema(&schema, map[string]any{"x": 1}, strfmt.Default) }, + ) +} diff --git a/schema.go b/schema.go index 9b49007..2d8d6b9 100644 --- a/schema.go +++ b/schema.go @@ -265,10 +265,12 @@ func (s *SchemaValidator) Validate(data any) *Result { continue } - result.Merge(v.Validate(d)) if s.Options.recycleValidators { - s.validators[idx] = nil // prevents further (unsafe) usage + // prevents further (unsafe) usage. Validate redeems it, even if it panics: + // redeemChildren must not redeem it again + s.validators[idx] = nil } + result.Merge(v.Validate(d)) result.Inc() } result.Inc() diff --git a/schema_props.go b/schema_props.go index d7ecb14..995a9cb 100644 --- a/schema_props.go +++ b/schema_props.go @@ -141,10 +141,10 @@ func (s *schemaPropsValidator) validateAnyOf(data any, mainResult, keepResultAny var bestFailures *Result for i, anyOfSchema := range s.anyOfValidators { - result := anyOfSchema.Validate(data) if s.Options.recycleValidators { - s.anyOfValidators[i] = nil + s.anyOfValidators[i] = nil // Validate redeems it, even if it panics } + result := anyOfSchema.Validate(data) // We keep inner IMPORTANT! errors no matter what MatchCount tells us keepResultAnyOf.Merge(result.keepRelevantErrors()) // merges (and redeems) a new instance of Result @@ -186,10 +186,10 @@ func (s *schemaPropsValidator) validateOneOf(data any, mainResult, keepResultOne ) for i, oneOfSchema := range s.oneOfValidators { - result := oneOfSchema.Validate(data) if s.Options.recycleValidators { - s.oneOfValidators[i] = nil + s.oneOfValidators[i] = nil // Validate redeems it, even if it panics } + result := oneOfSchema.Validate(data) // We keep inner IMPORTANT! errors no matter what MatchCount tells us keepResultOneOf.Merge(result.keepRelevantErrors()) // merges (and redeems) a new instance of Result @@ -242,10 +242,10 @@ func (s *schemaPropsValidator) validateAllOf(data any, mainResult, keepResultAll var validated int for i, allOfSchema := range s.allOfValidators { - result := allOfSchema.Validate(data) if s.Options.recycleValidators { - s.allOfValidators[i] = nil + s.allOfValidators[i] = nil // Validate redeems it, even if it panics } + result := allOfSchema.Validate(data) // We keep inner IMPORTANT! errors no matter what MatchCount tells us keepResultAllOf.Merge(result.keepRelevantErrors()) if result.IsValid() { @@ -264,10 +264,11 @@ func (s *schemaPropsValidator) validateAllOf(data any, mainResult, keepResultAll } func (s *schemaPropsValidator) validateNot(data any, mainResult *Result) { - result := s.notValidator.Validate(data) + notValidator := s.notValidator if s.Options.recycleValidators { - s.notValidator = nil + s.notValidator = nil // Validate redeems it, even if it panics } + result := notValidator.Validate(data) // We keep inner IMPORTANT! errors no matter what MatchCount tells us if result.IsValid() { mainResult.addErrorsAt(s.Path, mustNotValidatechemaMsg(s.Path.dotted())) From 64168cd66deeaac6c5a06726dad38177d0d4fce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20BIDON?= Date: Tue, 20 Oct 2026 21:40:37 +0200 Subject: [PATCH 2/2] fix: cap the validators built for allOf, anyOf, oneOf and not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A circular $ref through a composition keyword, such as A: {allOf: [{$ref: A}]}, made schema validation overflow the goroutine stack: a fatal error that recover cannot catch. go-openapi/spec bounds each expansion, and leaves a circular $ref in place. But newSchemaPropsValidator builds the allOf, anyOf, oneOf and not validators along with their parent, and each one expands its $ref again, one level down, in a new expansion. Nothing bounded that loop. With two branches, A: {allOf: [{$ref: A}, {$ref: A}]}, it builds 2^depth validators. Thread a compositionBudget through validator construction. It caps the nesting at maxCompositionDepth (128) and the count at maxCompositionValidators (10,000) per schema validator. Going over either cap panics with "Invalid schema provided to SchemaValidator", like any other schema that cannot be expanded. Only schemas with composition keywords create a budget, and it stays on the stack: no pre-walk, no allocation. Validators for properties and items are built as Validate walks the data, and start their own budget, so recursive schemas such as trees are not affected. SpecValidator no longer panics on such a schema when it validates defaults and examples (reached with ContinueOnErrors, since the circular ancestry check stops it otherwise): validateValueAgainstSchema returns the panic as an error at the location of the value. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frédéric BIDON --- default_validator.go | 4 +- example_validator.go | 9 +- schema.go | 15 ++- schema_composition_test.go | 206 +++++++++++++++++++++++++++++++++++++ schema_messages.go | 14 +++ schema_props.go | 66 +++++++++++- schema_props_test.go | 10 +- spec.go | 29 ++++++ 8 files changed, 330 insertions(+), 23 deletions(-) create mode 100644 schema_composition_test.go diff --git a/default_validator.go b/default_validator.go index 5902cee..93394d4 100644 --- a/default_validator.go +++ b/default_validator.go @@ -230,9 +230,7 @@ func (d *defaultValidator) validateDefaultValueSchemaAgainstSchema(path pathSegm s := d.SpecValidator if schema.Default != nil { - res.Merge( - newSchemaValidator(schema, s.spec.Spec(), path.child(jsonDefault), s.KnownFormats, d.schemaOptions).Validate(schema.Default), - ) + res.Merge(s.validateValueAgainstSchema(schema, path.child(jsonDefault), schema.Default, d.schemaOptions)) } if schema.Items != nil { if schema.Items.Schema != nil { diff --git a/example_validator.go b/example_validator.go index 0e193d5..a30ca87 100644 --- a/example_validator.go +++ b/example_validator.go @@ -214,10 +214,7 @@ func (ex *exampleValidator) validateExampleInResponse( exampleAt := responsePath(path, method, responseCodeAsStr). child(swaggerExamples). structuralChild(jsonMimeApplicationJSON) - res.MergeAsWarnings( - newSchemaValidator(response.Schema, s.spec.Spec(), - exampleAt, s.KnownFormats, s.schemaOptions).Validate(example), - ) + res.MergeAsWarnings(s.validateValueAgainstSchema(response.Schema, exampleAt, example, s.schemaOptions)) } else { // Proposal for enhancement: validate other media types too res.addWarningsAt(responsePath(path, method, responseCodeAsStr).child(swaggerExamples), examplesMimeNotSupportedMsg(operationID, responseName)) @@ -239,9 +236,7 @@ func (ex *exampleValidator) validateExampleValueSchemaAgainstSchema(path pathSeg res := validatorPools.results.Borrow() if schema.Example != nil { - res.MergeAsWarnings( - newSchemaValidator(schema, s.spec.Spec(), path.child(swaggerExample), s.KnownFormats, ex.schemaOptions).Validate(schema.Example), - ) + res.MergeAsWarnings(s.validateValueAgainstSchema(schema, path.child(swaggerExample), schema.Example, ex.schemaOptions)) } if schema.Items != nil { if schema.Items.Schema != nil { diff --git a/schema.go b/schema.go index 2d8d6b9..d8fd3ac 100644 --- a/schema.go +++ b/schema.go @@ -94,6 +94,15 @@ func rootPathFromString(root string) pathSegments { } func newSchemaValidator(schema *spec.Schema, rootSchema any, root pathSegments, formats strfmt.Registry, opts *SchemaValidatorOptions) *SchemaValidator { + return newSchemaValidatorWithBudget(schema, rootSchema, root, formats, opts, nil) +} + +// newSchemaValidatorWithBudget builds a schema validator, drawing the validators built for +// allOf, anyOf, oneOf and not from budget. A nil budget starts a new one if needed. +func newSchemaValidatorWithBudget( + schema *spec.Schema, rootSchema any, root pathSegments, formats strfmt.Registry, opts *SchemaValidatorOptions, + budget *compositionBudget, +) *SchemaValidator { if schema == nil { return nil } @@ -131,7 +140,7 @@ func newSchemaValidator(schema *spec.Schema, rootSchema any, root pathSegments, s.validators = [8]valueValidator{ s.typeValidator(), - s.schemaPropsValidator(), + s.schemaPropsValidator(budget), s.stringValidator(), s.formatValidator(), s.numberValidator(), @@ -354,11 +363,11 @@ func (s *SchemaValidator) formatValidator() valueValidator { ) } -func (s *SchemaValidator) schemaPropsValidator() valueValidator { +func (s *SchemaValidator) schemaPropsValidator(budget *compositionBudget) valueValidator { sch := s.Schema return newSchemaPropsValidator( s.path, s.in, sch.AllOf, sch.OneOf, sch.AnyOf, sch.Not, sch.Dependencies, s.Root, s.KnownFormats, - s.Options, + s.Options, budget, ) } diff --git a/schema_composition_test.go b/schema_composition_test.go new file mode 100644 index 0000000..a073ffb --- /dev/null +++ b/schema_composition_test.go @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package validate + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/go-openapi/loads" + "github.com/go-openapi/spec" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestCompositionBudget(t *testing.T) { + // each of these schemas loops through allOf, anyOf, oneOf or not and their $ref: + // without a budget, building the validator overflows the goroutine stack + // (fatal, not recoverable) or never ends + for _, tc := range []struct { + name string + schema string + }{ + { + name: "allOf cycle", + schema: `{"definitions":{"CycleAllOf":{"allOf":[{"$ref":"#/definitions/CycleAllOf"}]}},"properties":{"x":{"$ref":"#/definitions/CycleAllOf"}}}`, + }, + { + name: "anyOf cycle", + schema: `{"definitions":{"CycleAnyOf":{"anyOf":[{"type":"string"},{"$ref":"#/definitions/CycleAnyOf"}]}},"properties":{"x":{"$ref":"#/definitions/CycleAnyOf"}}}`, + }, + { + name: "oneOf cycle", + schema: `{"definitions":{"CycleOneOf":{"oneOf":[{"$ref":"#/definitions/CycleOneOf"}]}},"properties":{"x":{"$ref":"#/definitions/CycleOneOf"}}}`, + }, + { + name: "not cycle", + schema: `{"definitions":{"CycleNot":{"not":{"$ref":"#/definitions/CycleNot"}}},"properties":{"x":{"$ref":"#/definitions/CycleNot"}}}`, + }, + { + name: "mutual cycle", + schema: `{ + "definitions":{ + "CycleMutualA":{"allOf":[{"$ref":"#/definitions/CycleMutualB"}]}, + "CycleMutualB":{"anyOf":[{"$ref":"#/definitions/CycleMutualA"}]} + }, + "properties":{"x":{"$ref":"#/definitions/CycleMutualA"}} + }`, + }, + { + // 2 branches per level: 2^depth validators without a budget + name: "fan-out cycle", + schema: `{ + "definitions":{"CycleFanOut":{"allOf":[{"$ref":"#/definitions/CycleFanOut"},{"$ref":"#/definitions/CycleFanOut"}]}}, + "properties":{"x":{"$ref":"#/definitions/CycleFanOut"}} + }`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var schema spec.Schema + require.NoError(t, json.Unmarshal([]byte(tc.schema), &schema)) + + defer func() { + r := recover() + require.NotNil(t, r) + msg, ok := r.(string) + require.TrueT(t, ok) + assert.True(t, strings.HasPrefix(msg, "Invalid schema provided to SchemaValidator: ")) + assert.True(t, strings.Contains(msg, fmt.Sprintf("deeper than %d levels or into more than %d validators", maxCompositionDepth, maxCompositionValidators))) + }() + + _ = AgainstSchema(&schema, map[string]any{"x": 1}, strfmt.Default) + }) + } +} + +func TestCompositionBudgetRecursiveSchema(t *testing.T) { + // a $ref back to an ancestor through properties or items is built as the data is walked: + // the depth of the data bounds it, and it does not draw from the budget + const tree = `{ + "definitions":{ + "BudgetNode":{ + "allOf":[ + {"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}, + {"properties":{"children":{"type":"array","items":{"$ref":"#/definitions/BudgetNode"}}}} + ] + } + }, + "properties":{"root":{"$ref":"#/definitions/BudgetNode"}} + }` + + var schema spec.Schema + require.NoError(t, json.Unmarshal([]byte(tree), &schema)) + + // nest the data deeper than both caps: each level only builds the validators of one Node + const depth = 2 * maxCompositionDepth + node := map[string]any{nameProp: "leaf"} + for range depth { + node = map[string]any{nameProp: "node", "children": []any{node}} + } + + require.NoError(t, AgainstSchema(&schema, map[string]any{"root": node}, strfmt.Default)) + + node["children"] = []any{map[string]any{"children": []any{}}} // missing name + require.Error(t, AgainstSchema(&schema, map[string]any{"root": node}, strfmt.Default)) +} + +func TestCompositionBudgetWideSchema(t *testing.T) { + // many siblings within the budget still build + const width = maxCompositionValidators + branches := make([]spec.Schema, width) + for i := range branches { + branches[i] = *spec.StringProperty() + } + schema := &spec.Schema{SchemaProps: spec.SchemaProps{AllOf: branches}} + + require.NoError(t, AgainstSchema(schema, "a", strfmt.Default)) + require.Error(t, AgainstSchema(schema, 1, strfmt.Default)) + + // one more panics + schema.AllOf = append(schema.AllOf, *spec.StringProperty()) + require.Panics(t, func() { _ = AgainstSchema(schema, "a", strfmt.Default) }) +} + +func TestCompositionBudgetAmplification(t *testing.T) { + // no cycle, and within maxCompositionDepth: A0 refers twice to A1, which refers twice to A2, + // and so on. Expanding it stays within the budget of go-openapi/spec, but it would build + // 2^levels validators. + const levels = 13 + definitions := make(map[string]any, levels+1) + for i := range levels { + ref := map[string]any{jsonRef: fmt.Sprintf("#/definitions/BudgetA%d", i+1)} + definitions[fmt.Sprintf("BudgetA%d", i)] = map[string]any{"allOf": []any{ref, ref}} + } + definitions[fmt.Sprintf("BudgetA%d", levels)] = map[string]any{jsonType: stringType} + + doc, err := json.Marshal(map[string]any{ + swaggerDefinitions: definitions, + "properties": map[string]any{"x": map[string]any{jsonRef: "#/definitions/BudgetA0"}}, + }) + require.NoError(t, err) + + var schema spec.Schema + require.NoError(t, json.Unmarshal(doc, &schema)) + + require.PanicsWithValue(t, + invalidSchemaProvidedMsg(tooManyCompositionValidatorsMsg("x", maxCompositionDepth, maxCompositionValidators)).Error(), + func() { _ = AgainstSchema(&schema, map[string]any{"x": "a"}, strfmt.Default) }, + ) +} + +func TestCompositionBudgetDeepSchema(t *testing.T) { + // nesting allOf within the depth cap still builds + schema := spec.StringProperty() + for range maxCompositionDepth { + schema = &spec.Schema{SchemaProps: spec.SchemaProps{AllOf: []spec.Schema{*schema}}} + } + + require.NoError(t, AgainstSchema(schema, "a", strfmt.Default)) + require.Error(t, AgainstSchema(schema, 1, strfmt.Default)) + + // one level more panics + schema = &spec.Schema{SchemaProps: spec.SchemaProps{AllOf: []spec.Schema{*schema}}} + require.Panics(t, func() { _ = AgainstSchema(schema, "a", strfmt.Default) }) +} + +func TestCompositionBudgetSpec(t *testing.T) { + // Spec stops on the circular ancestry of CycleSpec before it checks defaults and examples. + // ContinueOnErrors goes on: building a validator for the default or the example panics, + // and the spec validator returns it as an error (default) or a warning (example). + const doc = `{ + "swagger":"2.0", + "info":{"title":"t","version":"1"}, + "paths":{}, + "definitions":{ + "CycleSpec":{"default":1,"example":1,"allOf":[{"$ref":"#/definitions/CycleSpec"}]} + } + }` + + analyzed, err := loads.Analyzed(json.RawMessage(doc), "") + require.NoError(t, err) + + validator := NewSpecValidator(analyzed.Schema(), strfmt.Default) + validator.SetContinueOnErrors(true) + + var errs, warnings *Result + require.NotPanics(t, func() { errs, warnings = validator.Validate(analyzed) }) + + const tooDeep = "nests allOf, anyOf, oneOf and not deeper than" + assert.True(t, hasMessageContaining(errs.Errors, "has circular ancestry")) + assert.True(t, hasMessageContaining(errs.Errors, tooDeep)) + assert.True(t, hasMessageContaining(warnings.Errors, tooDeep)) +} + +func hasMessageContaining(errs []error, substr string) bool { + for _, err := range errs { + if strings.Contains(err.Error(), substr) { + return true + } + } + + return false +} diff --git a/schema_messages.go b/schema_messages.go index e0f1801..d2af486 100644 --- a/schema_messages.go +++ b/schema_messages.go @@ -4,6 +4,8 @@ package validate import ( + "strings" + "github.com/go-openapi/errors" ) @@ -34,10 +36,18 @@ const ( // NOTE: punctuation in message. MustValidateAllSchemasError = "%q must validate all the schemas (allOf)%s" + // TooManyCompositionValidatorsError indicates that allOf, anyOf, oneOf and not, followed through + // their $ref, nest too deep or need too many validators. A circular $ref through these + // keywords is the usual cause. + TooManyCompositionValidatorsError = "%q nests allOf, anyOf, oneOf and not deeper than %d levels or into more than %d validators: look for a circular $ref" + // MustNotValidateSchemaError indicates that in a Not construct, the schema constraint specified was verified. MustNotValidateSchemaError = "%q must not validate the schema (not)" ) +// invalidSchemaProvidedPrefix starts the panic message of a schema validator that cannot be built. +var invalidSchemaProvidedPrefix = strings.TrimSuffix(InvalidSchemaProvidedError, "%v") + // Warning messages related to schema validation and returned as results. const () @@ -45,6 +55,10 @@ func invalidSchemaProvidedMsg(err error) errors.Error { return errors.New(InternalErrorCode, InvalidSchemaProvidedError, err) } +func tooManyCompositionValidatorsMsg(path string, maxDepth, maxValidators int) errors.Error { + return errors.New(InternalErrorCode, TooManyCompositionValidatorsError, path, maxDepth, maxValidators) +} + func invalidTypeConversionMsg(path string, err error) errors.Error { return errors.New(errors.CompositeErrorCode, InvalidTypeConversionError, path, err) } diff --git a/schema_props.go b/schema_props.go index 995a9cb..29a761f 100644 --- a/schema_props.go +++ b/schema_props.go @@ -11,6 +11,48 @@ import ( "github.com/go-openapi/strfmt" ) +const ( + // maxCompositionDepth caps the nesting of allOf, anyOf, oneOf and not, followed through + // their $ref, below one schema validator. + maxCompositionDepth = 128 + + // maxCompositionValidators caps the validators built for allOf, anyOf, oneOf and not, followed + // through their $ref, below one schema validator. + maxCompositionValidators = 10_000 +) + +// compositionBudget bounds the validators built for allOf, anyOf, oneOf and not. +// +// These validators are built with their parent, not when Validate walks the data, and each one +// expands its own $ref. go-openapi/spec bounds a single expansion, but a circular $ref stays in +// place and is expanded again one level down: A: {allOf: [{$ref: A}]} would build validators +// until the goroutine stack overflows, and A: {allOf: [{$ref: A}, {$ref: A}]} would build +// 2^depth of them. +// +// Validators built for properties, items and the like are built as Validate walks the data, so +// the depth of the data bounds them. They start their own budget. +type compositionBudget struct { + left int // validators left to build + depth int // current nesting of allOf, anyOf, oneOf and not +} + +// enter draws n validators one level deeper, and panics when either cap is exceeded, +// like other schemas that cannot be expanded. +func (b *compositionBudget) enter(n int, path pathSegments) { + b.left -= n + b.depth++ + + if b.left < 0 || b.depth > maxCompositionDepth { + panic(invalidSchemaProvidedMsg( + tooManyCompositionValidatorsMsg(path.dotted(), maxCompositionDepth, maxCompositionValidators), + ).Error()) + } +} + +func (b *compositionBudget) leave() { + b.depth-- +} + type schemaPropsValidator struct { Path pathSegments In string @@ -30,28 +72,42 @@ type schemaPropsValidator struct { func newSchemaPropsValidator( path pathSegments, in string, allOf, oneOf, anyOf []spec.Schema, not *spec.Schema, deps spec.Dependencies, root any, formats strfmt.Registry, - opts *SchemaValidatorOptions, + opts *SchemaValidatorOptions, budget *compositionBudget, ) *schemaPropsValidator { if opts == nil { opts = new(SchemaValidatorOptions) } + children := len(anyOf) + len(allOf) + len(oneOf) + if not != nil { + children++ + } + + if children > 0 { + if budget == nil { + budget = &compositionBudget{left: maxCompositionValidators} + } + + budget.enter(children, path) + defer budget.leave() + } + anyValidators := make([]*SchemaValidator, 0, len(anyOf)) for i := range anyOf { - anyValidators = append(anyValidators, newSchemaValidator(&anyOf[i], root, path, formats, opts)) + anyValidators = append(anyValidators, newSchemaValidatorWithBudget(&anyOf[i], root, path, formats, opts, budget)) } allValidators := make([]*SchemaValidator, 0, len(allOf)) for i := range allOf { - allValidators = append(allValidators, newSchemaValidator(&allOf[i], root, path, formats, opts)) + allValidators = append(allValidators, newSchemaValidatorWithBudget(&allOf[i], root, path, formats, opts, budget)) } oneValidators := make([]*SchemaValidator, 0, len(oneOf)) for i := range oneOf { - oneValidators = append(oneValidators, newSchemaValidator(&oneOf[i], root, path, formats, opts)) + oneValidators = append(oneValidators, newSchemaValidatorWithBudget(&oneOf[i], root, path, formats, opts, budget)) } var notValidator *SchemaValidator if not != nil { - notValidator = newSchemaValidator(not, root, path, formats, opts) + notValidator = newSchemaValidatorWithBudget(not, root, path, formats, opts, budget) } var s *schemaPropsValidator diff --git a/schema_props_test.go b/schema_props_test.go index bd8a203..b981354 100644 --- a/schema_props_test.go +++ b/schema_props_test.go @@ -18,7 +18,7 @@ import ( func TestSchemaPropsValidator_EdgeCases(t *testing.T) { t.Run("should validate props against empty validator", func(t *testing.T) { s := newSchemaPropsValidator( - nil, "", nil, nil, nil, nil, nil, nil, strfmt.Default, nil) + nil, "", nil, nil, nil, nil, nil, nil, strfmt.Default, nil, nil) s.setPath(newPathSegments("path")) assert.EqualT(t, "path", s.Path.dotted()) }) @@ -30,7 +30,7 @@ func TestSchemaPropsValidator_EdgeCases(t *testing.T) { []spec.Schema{ *spec.StringProperty(), *spec.StrFmtProperty("date"), - }, nil, nil, nil, nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}) + }, nil, nil, nil, nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}, nil) } t.Run("should validate date string", func(t *testing.T) { @@ -69,7 +69,7 @@ func TestSchemaPropsValidator_EdgeCases(t *testing.T) { []spec.Schema{ *spec.Int64Property(), *spec.StrFmtProperty("date"), - }, nil, nil, nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}) + }, nil, nil, nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}, nil) } t.Run("should validate date string", func(t *testing.T) { @@ -100,7 +100,7 @@ func TestSchemaPropsValidator_EdgeCases(t *testing.T) { []spec.Schema{ *spec.StringProperty(), *spec.StrFmtProperty("date"), - }, nil, nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}) + }, nil, nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}, nil) } t.Run("should validate date string", func(t *testing.T) { @@ -130,7 +130,7 @@ func TestSchemaPropsValidator_EdgeCases(t *testing.T) { nil, nil, spec.StringProperty(), - nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}) + nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}, nil) } t.Run("should validate number", func(t *testing.T) { diff --git a/spec.go b/spec.go index 1337523..e06688e 100644 --- a/spec.go +++ b/spec.go @@ -624,6 +624,35 @@ func (s *SpecValidator) validateReferencedDefinitions() *Result { return result } +// validateValueAgainstSchema validates a default or an example against a schema declared by the spec. +// +// newSchemaValidator panics on a schema it cannot build: a $ref it cannot expand, or a circular $ref +// through allOf that exceeds maxCompositionDepth or maxCompositionValidators. Here the schema comes +// from the spec under validation, so the panic is returned as an error at the location of the value. +// Other panics are not recovered. +func (s *SpecValidator) validateValueAgainstSchema( + schema *spec.Schema, at pathSegments, value any, opts *SchemaValidatorOptions, +) (res *Result) { + defer func() { + r := recover() + if r == nil { + return + } + + msg, ok := r.(string) + if !ok || !strings.HasPrefix(msg, invalidSchemaProvidedPrefix) { + panic(r) + } + + // the validators and results borrowed before the panic are not redeemed: the garbage + // collector reclaims them + res = validatorPools.results.Borrow() + res.addErrorsAt(at, errors.New(InternalErrorCode, "%s", msg)) + }() + + return newSchemaValidator(schema, s.spec.Spec(), at, s.KnownFormats, opts).Validate(value) +} + func (s *SpecValidator) validateRequiredDefinitions() *Result { // Each property listed in the required array must be defined in the properties of the model res := validatorPools.results.Borrow()