diff --git a/schema.go b/schema.go index 9b49007..818edec 100644 --- a/schema.go +++ b/schema.go @@ -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) @@ -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 { @@ -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. diff --git a/schema_composition_depth.go b/schema_composition_depth.go new file mode 100644 index 0000000..5d1ef45 --- /dev/null +++ b/schema_composition_depth.go @@ -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 +} diff --git a/schema_messages.go b/schema_messages.go index e0f1801..e3d1d39 100644 --- a/schema_messages.go +++ b/schema_messages.go @@ -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)" @@ -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) } diff --git a/schema_option.go b/schema_option.go index 09a03eb..fb59140 100644 --- a/schema_option.go +++ b/schema_option.go @@ -14,6 +14,7 @@ import ( type SchemaValidatorOptions struct { EnableObjectArrayTypeCheck bool EnableArrayMustHaveItemsCheck bool + maxSchemaCompositionDepth int recycleValidators bool recycleResult bool skipSchemataResult bool @@ -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) { @@ -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) { @@ -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 diff --git a/schema_option_test.go b/schema_option_test.go index ddf7c08..386c1ad 100644 --- a/schema_option_test.go +++ b/schema_option_test.go @@ -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() @@ -39,6 +51,7 @@ func TestSchemaOptions(t *testing.T) { opts := &SchemaValidatorOptions{ EnableObjectArrayTypeCheck: true, EnableArrayMustHaveItemsCheck: true, + maxSchemaCompositionDepth: 128, recycleValidators: true, recycleResult: true, skipSchemataResult: true, diff --git a/schema_test.go b/schema_test.go index 2e14641..e4c31f8 100644 --- a/schema_test.go +++ b/schema_test.go @@ -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 +} diff --git a/spec.go b/spec.go index 1337523..fcebfc5 100644 --- a/spec.go +++ b/spec.go @@ -143,6 +143,11 @@ func (s *SpecValidator) Validate(data any) (*Result, *Result) { }() // 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 @@ -624,6 +629,100 @@ func (s *SpecValidator) validateReferencedDefinitions() *Result { 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 +} + 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() diff --git a/spec_test.go b/spec_test.go index 99dd040..838391a 100644 --- a/spec_test.go +++ b/spec_test.go @@ -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")