Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions default_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 2 additions & 7 deletions example_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions pools_leak_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
)
}
21 changes: 16 additions & 5 deletions schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -265,10 +274,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()
Expand Down Expand Up @@ -352,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,
)
}

Expand Down
206 changes: 206 additions & 0 deletions schema_composition_test.go
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 14 additions & 0 deletions schema_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package validate

import (
"strings"

"github.com/go-openapi/errors"
)

Expand Down Expand Up @@ -34,17 +36,29 @@ 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 ()

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)
}
Expand Down
Loading
Loading