Skip to content
Closed
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
21 changes: 20 additions & 1 deletion schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ type SchemaValidator struct {
//
// When no pre-parsed *[spec.Schema] structure is provided, it uses a JSON schema as default. See example.
func AgainstSchema(schema *spec.Schema, data any, formats strfmt.Registry, options ...Option) error {
opts := new(SchemaValidatorOptions)
for _, o := range options {
o(opts)
}

if exceededAt, exceeded := firstSchemaCompositionDepthExceeded(schema, rootPath(), opts.maxSchemaCompositionDepthLimit()); exceeded {
return errors.CompositeValidationError(
schemaCompositionNestingTooDeepMsg(exceededAt.dotted(), opts.maxSchemaCompositionDepthLimit()),
)
}

res := NewSchemaValidator(schema, nil, "", formats,
append(options, WithRecycleValidators(true), withRecycleResults(true))...,
).Validate(data)
Expand All @@ -56,11 +67,19 @@ func AgainstSchema(schema *spec.Schema, data any, formats strfmt.Registry, optio
//
// Panics if the provided schema is invalid.
func NewSchemaValidator(schema *spec.Schema, rootSchema any, root string, formats strfmt.Registry, options ...Option) *SchemaValidator {
rootPath := rootPathFromString(root)

opts := new(SchemaValidatorOptions)
for _, o := range options {
o(opts)
}

if exceededAt, exceeded := firstSchemaCompositionDepthExceeded(schema, rootPath, opts.maxSchemaCompositionDepthLimit()); exceeded {
panic(invalidSchemaProvidedMsg(
schemaCompositionNestingTooDeepMsg(exceededAt.dotted(), opts.maxSchemaCompositionDepthLimit()),
).Error())
}

// the caller still owns this schema, and validation expands what it walks: work on a copy,
// once, here - every validator below this one is then free to expand in place
if !opts.ownSchemata && schema != nil {
Expand All @@ -77,7 +96,7 @@ func NewSchemaValidator(schema *spec.Schema, rootSchema any, root string, format
opts.ownSchemata = true
}

return newSchemaValidator(schema, rootSchema, rootPathFromString(root), formats, opts)
return newSchemaValidator(schema, rootSchema, rootPath, formats, opts)
}

// rootPathFromString interprets the root path of the exported constructors.
Expand Down
68 changes: 68 additions & 0 deletions schema_composition_depth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package validate

import "github.com/go-openapi/spec"

type schemaCompositionFrame struct {
schema *spec.Schema
path pathSegments
depth int
}

func firstSchemaCompositionDepthExceeded(schema *spec.Schema, at pathSegments, maxDepth int) (pathSegments, bool) {
if schema == nil {
return nil, false
}

stack := []schemaCompositionFrame{{
schema: schema,
path: at,
depth: 0,
}}

for len(stack) > 0 {
last := len(stack) - 1
frame := stack[last]
stack = stack[:last]

if frame.depth > maxDepth {
return frame.path, true
}

if frame.schema.Not != nil {
stack = append(stack, schemaCompositionFrame{
schema: frame.schema.Not,
path: frame.path.child(jsonNot),
depth: frame.depth + 1,
})
}

for i := len(frame.schema.OneOf) - 1; i >= 0; i-- {
stack = append(stack, schemaCompositionFrame{
schema: &frame.schema.OneOf[i],
path: frame.path.child(jsonOneOf).item(i),
depth: frame.depth + 1,
})
}

for i := len(frame.schema.AnyOf) - 1; i >= 0; i-- {
stack = append(stack, schemaCompositionFrame{
schema: &frame.schema.AnyOf[i],
path: frame.path.child(jsonAnyOf).item(i),
depth: frame.depth + 1,
})
}

for i := len(frame.schema.AllOf) - 1; i >= 0; i-- {
stack = append(stack, schemaCompositionFrame{
schema: &frame.schema.AllOf[i],
path: frame.path.child(jsonAllOf).item(i),
depth: frame.depth + 1,
})
}
}

return nil, false
}
7 changes: 7 additions & 0 deletions schema_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const (
// InvalidTypeConversionError indicates that a numerical conversion for the given type could not be carried on.
InvalidTypeConversionError = "invalid type conversion in %s: %v "

// SchemaCompositionNestingTooDeepError indicates that composition keywords are nested beyond the configured safety limit.
SchemaCompositionNestingTooDeepError = "%q schema composition nesting exceeds limit of %d"

// MustValidateAtLeastOneSchemaError indicates that in a AnyOf construct, none of the schema constraints specified were verified.
MustValidateAtLeastOneSchemaError = "%q must validate at least one schema (anyOf)"

Expand All @@ -49,6 +52,10 @@ func invalidTypeConversionMsg(path string, err error) errors.Error {
return errors.New(errors.CompositeErrorCode, InvalidTypeConversionError, path, err)
}

func schemaCompositionNestingTooDeepMsg(path string, limit int) errors.Error {
return errors.New(errors.CompositeErrorCode, SchemaCompositionNestingTooDeepError, path, limit)
}

func mustValidateOnlyOneSchemaMsg(path, additionalMsg string) errors.Error {
return errors.New(errors.CompositeErrorCode, MustValidateOnlyOneSchemaError, path, additionalMsg)
}
Expand Down
20 changes: 20 additions & 0 deletions schema_option.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
type SchemaValidatorOptions struct {
EnableObjectArrayTypeCheck bool
EnableArrayMustHaveItemsCheck bool
maxSchemaCompositionDepth int
recycleValidators bool
recycleResult bool
skipSchemataResult bool
Expand All @@ -32,6 +33,8 @@ type SchemaValidatorOptions struct {
// Option sets optional rules for schema validation.
type Option func(*SchemaValidatorOptions)

const defaultMaxSchemaCompositionDepth = 256

// EnableObjectArrayTypeCheck activates the swagger rule: an items must be in type: array.
func EnableObjectArrayTypeCheck(enable bool) Option {
return func(svo *SchemaValidatorOptions) {
Expand Down Expand Up @@ -70,6 +73,14 @@ func withRecycleResults(enable bool) Option {
}
}

// WithMaxSchemaCompositionDepth sets the maximum allowed nesting depth for schema composition
// keywords (allOf, anyOf, oneOf, not). Values <= 0 fall back to the default limit.
func WithMaxSchemaCompositionDepth(limit int) Option {
return func(svo *SchemaValidatorOptions) {
svo.maxSchemaCompositionDepth = limit
}
}

// WithSkipSchemataResult skips the deep audit payload stored in validation Result.
func WithSkipSchemataResult(enable bool) Option {
return func(svo *SchemaValidatorOptions) {
Expand Down Expand Up @@ -101,11 +112,20 @@ func (svo SchemaValidatorOptions) Options() []Option {
EnableArrayMustHaveItemsCheck(svo.EnableArrayMustHaveItemsCheck),
WithRecycleValidators(svo.recycleValidators),
withRecycleResults(svo.recycleResult),
WithMaxSchemaCompositionDepth(svo.maxSchemaCompositionDepth),
WithSkipSchemataResult(svo.skipSchemataResult),
WithPathLoader(svo.pathLoaderWithOptions),
}
}

func (svo *SchemaValidatorOptions) maxSchemaCompositionDepthLimit() int {
if svo == nil || svo.maxSchemaCompositionDepth <= 0 {
return defaultMaxSchemaCompositionDepth
}

return svo.maxSchemaCompositionDepth
}

// expandOptions builds the spec expand options for schema/$ref expansion during validation,
// carrying the injected loader (when set) so resolution can be confined. relativeBase is used for
// base-path-relative resolution; it is ignored by [spec.ExpandSchemaWithOptions], which derives the
Expand Down
13 changes: 13 additions & 0 deletions schema_option_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ func TestSchemaOptions(t *testing.T) {
require.TrueT(t, opts.skipSchemataResult)
})

t.Run("maxSchemaCompositionDepth", func(t *testing.T) {
opts := &SchemaValidatorOptions{}
setter := WithMaxSchemaCompositionDepth(42)
setter(opts)
require.Equal(t, 42, opts.maxSchemaCompositionDepthLimit())
})

t.Run("default maxSchemaCompositionDepth", func(t *testing.T) {
opts := &SchemaValidatorOptions{}
require.Equal(t, defaultMaxSchemaCompositionDepth, opts.maxSchemaCompositionDepthLimit())
})

t.Run("default Options()", func(t *testing.T) {
opts := &SchemaValidatorOptions{}
setters := opts.Options()
Expand All @@ -39,6 +51,7 @@ func TestSchemaOptions(t *testing.T) {
opts := &SchemaValidatorOptions{
EnableObjectArrayTypeCheck: true,
EnableArrayMustHaveItemsCheck: true,
maxSchemaCompositionDepth: 128,
recycleValidators: true,
recycleResult: true,
skipSchemataResult: true,
Expand Down
28 changes: 28 additions & 0 deletions schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,31 @@ func TestSchemaValidator_TypeArray_Issue83(t *testing.T) {
// swagger behavior
require.Error(t, AgainstSchema(schema, input, strfmt.Default, SwaggerSchema(true)))
}

func TestAgainstSchema_CompositionDepthLimit(t *testing.T) {
schema := deepAllOfSchema(6)
input := map[string]any{}

err := AgainstSchema(schema, input, strfmt.Default, WithMaxSchemaCompositionDepth(4))
require.Error(t, err)
require.ErrorContains(t, err, "schema composition nesting exceeds limit of 4")

require.NoError(t, AgainstSchema(schema, input, strfmt.Default, WithMaxSchemaCompositionDepth(8)))
}

func deepAllOfSchema(depth int) *spec.Schema {
schema := &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{objectType},
},
}
for i := 0; i < depth; i++ {
schema = &spec.Schema{
SchemaProps: spec.SchemaProps{
AllOf: []spec.Schema{*schema},
},
}
}

return schema
}
99 changes: 99 additions & 0 deletions spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@
}()

// Swagger schema validator
errs.Merge(s.validateSchemaCompositionDepth()) // error -
if !s.Options.ContinueOnErrors && errs.HasErrors() {
return errs, warnings // no point in continuing
}

schv := newSchemaValidator(s.schema, nil, rootPath(), s.KnownFormats, s.schemaOptions)
errs.Merge(schv.Validate(obj)) // error -
// There may be a point in continuing to try and determine more accurate errors
Expand Down Expand Up @@ -624,6 +629,100 @@
return result
}

func (s *SpecValidator) validateSchemaCompositionDepth() *Result {
res := validatorPools.results.Borrow()
if s.spec == nil || s.spec.Spec() == nil {
return res
}

sp := s.spec.Spec()
maxDepth := s.schemaOptions.maxSchemaCompositionDepthLimit()

for _, name := range sortedKeys(sp.Definitions) {
schema := sp.Definitions[name]
at := newPathSegments(swaggerDefinitions, name)
if exceededAt, exceeded := firstSchemaCompositionDepthExceeded(&schema, at, maxDepth); exceeded {
res.addErrorsAt(exceededAt, schemaCompositionNestingTooDeepMsg(exceededAt.dotted(), maxDepth))
}
}

for _, name := range sortedKeys(sp.Parameters) {
param := sp.Parameters[name]
if param.Schema == nil {
continue
}

at := newPathSegments(swaggerParameters, name).structuralChild(jsonSchema)
if exceededAt, exceeded := firstSchemaCompositionDepthExceeded(param.Schema, at, maxDepth); exceeded {
res.addErrorsAt(exceededAt, schemaCompositionNestingTooDeepMsg(exceededAt.dotted(), maxDepth))
}
}

for _, name := range sortedKeys(sp.Responses) {
resp := sp.Responses[name]
if resp.Schema == nil {
continue
}

at := newPathSegments(swaggerResponses, name).structuralChild(jsonSchema)
if exceededAt, exceeded := firstSchemaCompositionDepthExceeded(resp.Schema, at, maxDepth); exceeded {
res.addErrorsAt(exceededAt, schemaCompositionNestingTooDeepMsg(exceededAt.dotted(), maxDepth))
}
}

if sp.Paths == nil {
return res
}

for _, path := range sortedKeys(sp.Paths.Paths) {
pathItem := sp.Paths.Paths[path]
pathAt := newPathSegments(swaggerPaths, path)

for i := range pathItem.Parameters {
param := pathItem.Parameters[i]
if param.Schema == nil {
continue
}

at := pathAt.child(swaggerParameters).item(i).structuralChild(jsonSchema)
if exceededAt, exceeded := firstSchemaCompositionDepthExceeded(param.Schema, at, maxDepth); exceeded {
res.addErrorsAt(exceededAt, schemaCompositionNestingTooDeepMsg(exceededAt.dotted(), maxDepth))
}
}

for method, op := range operationsOf(&pathItem) {
if op == nil {
continue
}

for i := range op.Parameters {
param := op.Parameters[i]
if param.Schema == nil {
continue
}

at := operationPath(path, method).child(swaggerParameters).item(i).structuralChild(jsonSchema)
if exceededAt, exceeded := firstSchemaCompositionDepthExceeded(param.Schema, at, maxDepth); exceeded {
res.addErrorsAt(exceededAt, schemaCompositionNestingTooDeepMsg(exceededAt.dotted(), maxDepth))
}
}

for _, response := range responsesOf(op) {
if response.resp.Schema == nil {
continue
}

at := responsePath(path, method, response.code).structuralChild(jsonSchema)
if exceededAt, exceeded := firstSchemaCompositionDepthExceeded(response.resp.Schema, at, maxDepth); exceeded {
res.addErrorsAt(exceededAt, schemaCompositionNestingTooDeepMsg(exceededAt.dotted(), maxDepth))
}
}
}
}

return res
}

Check notice on line 724 in spec.go

View check run for this annotation

codefactor.io / CodeFactor

spec.go#L632-L724

Complex Method

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()
Expand Down
18 changes: 18 additions & 0 deletions spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,24 @@ func TestSpec_ExpandResponseRecursive(t *testing.T) {
assert.Empty(t, res.Errors)
}

func TestSpec_CompositionDepthLimit(t *testing.T) {
doc, err := loads.Analyzed(nestedSpecWithAllOfDepth(8), "2.0")
require.NoError(t, err)

err = Spec(doc, strfmt.Default, WithMaxSchemaCompositionDepth(6))
require.Error(t, err)
require.ErrorContains(t, err, "schema composition nesting exceeds limit of 6")
require.ErrorContains(t, err, "definitions.X.allOf")

require.NoError(t, Spec(doc, strfmt.Default, WithMaxSchemaCompositionDepth(10)))
}

func nestedSpecWithAllOfDepth(depth int) []byte {
schema := strings.Repeat(`{"allOf":[`, depth) + `{"type":"object"}` + strings.Repeat(`]}`, depth)
doc := `{"swagger":"2.0","info":{"title":"t","version":"0"},"paths":{},"definitions":{"X":` + schema + `}}`
return []byte(doc)
}

// Spec with no path.
func TestSpec_Issue52(t *testing.T) {
fp := filepath.Join("testdata", "bugs", "52", "swagger.json")
Expand Down