From 74d1dacfdba5d9dc00b8cae58daee56b826714ad Mon Sep 17 00:00:00 2001 From: Stefan Selent Date: Thu, 9 Apr 2026 21:58:55 -0700 Subject: [PATCH 1/3] Add opt-in readOnly/writeOnly rejection to strict mode When StrictRejectReadOnly is enabled, readOnly properties in requests are reported as validation errors instead of being silently skipped. When StrictRejectWriteOnly is enabled, writeOnly properties in responses are reported similarly. Addresses https://github.com/pb33f/libopenapi-validator/issues/90 --- config/config.go | 22 +++++++++++ errors/strict_errors.go | 68 +++++++++++++++++++++++++++++++--- requests/validate_request.go | 34 +++++++++++------ responses/validate_response.go | 34 +++++++++++------ strict/polymorphic.go | 16 ++++++++ strict/property_collector.go | 20 ++++++++++ strict/schema_walker.go | 13 ++++++- strict/types.go | 27 +++++++++++++- 8 files changed, 203 insertions(+), 31 deletions(-) diff --git a/config/config.go b/config/config.go index 2198f43b..f67ec0f0 100644 --- a/config/config.go +++ b/config/config.go @@ -42,6 +42,8 @@ type ValidationOptions struct { StrictIgnorePaths []string // Instance JSONPath patterns to exclude from strict checks StrictIgnoredHeaders []string // Headers to always ignore in strict mode (nil = use defaults) strictIgnoredHeadersMerge bool // Internal: true if merging with defaults + StrictRejectReadOnly bool // Reject readOnly properties in requests + StrictRejectWriteOnly bool // Reject writeOnly properties in responses } // Option Enables an 'Options pattern' approach @@ -88,6 +90,8 @@ func WithExistingOpts(options *ValidationOptions) Option { o.StrictIgnorePaths = options.StrictIgnorePaths o.StrictIgnoredHeaders = options.StrictIgnoredHeaders o.strictIgnoredHeadersMerge = options.strictIgnoredHeadersMerge + o.StrictRejectReadOnly = options.StrictRejectReadOnly + o.StrictRejectWriteOnly = options.StrictRejectWriteOnly } } } @@ -241,6 +245,24 @@ func WithStrictIgnorePaths(paths ...string) Option { } } +// WithStrictRejectReadOnly enables rejection of readOnly properties in requests. +// When enabled, readOnly properties present in request bodies are reported as +// validation errors instead of being silently skipped. +func WithStrictRejectReadOnly() Option { + return func(o *ValidationOptions) { + o.StrictRejectReadOnly = true + } +} + +// WithStrictRejectWriteOnly enables rejection of writeOnly properties in responses. +// When enabled, writeOnly properties present in response bodies are reported as +// validation errors instead of being silently skipped. +func WithStrictRejectWriteOnly() Option { + return func(o *ValidationOptions) { + o.StrictRejectWriteOnly = true + } +} + // WithStrictIgnoredHeaders replaces the default ignored headers list entirely. // Use this to fully control which headers are ignored in strict mode. // For the default list, see the strict package's DefaultIgnoredHeaders. diff --git a/errors/strict_errors.go b/errors/strict_errors.go index aac6e3c5..5bbe8e0d 100644 --- a/errors/strict_errors.go +++ b/errors/strict_errors.go @@ -11,12 +11,14 @@ import ( // StrictValidationType is the validation type for strict mode errors. const StrictValidationType = "strict" -// StrictValidationSubTypes for different kinds of undeclared values. +// StrictValidationSubTypes for different kinds of strict validation errors. const ( - StrictSubTypeProperty = "undeclared-property" - StrictSubTypeHeader = "undeclared-header" - StrictSubTypeQuery = "undeclared-query-param" - StrictSubTypeCookie = "undeclared-cookie" + StrictSubTypeProperty = "undeclared-property" + StrictSubTypeHeader = "undeclared-header" + StrictSubTypeQuery = "undeclared-query-param" + StrictSubTypeCookie = "undeclared-cookie" + StrictSubTypeReadOnlyProperty = "readonly-property" + StrictSubTypeWriteOnlyProperty = "writeonly-property" ) // UndeclaredPropertyError creates a ValidationError for an undeclared property. @@ -132,6 +134,62 @@ func UndeclaredCookieError( } } +// ReadOnlyPropertyError creates a ValidationError for a readOnly property in a request. +func ReadOnlyPropertyError( + path string, + name string, + value any, + requestPath string, + requestMethod string, + specLine int, + specCol int, +) *ValidationError { + return &ValidationError{ + ValidationType: StrictValidationType, + ValidationSubType: StrictSubTypeReadOnlyProperty, + Message: fmt.Sprintf("request property '%s' at '%s' is readOnly and should not be sent in the request", + name, path), + Reason: fmt.Sprintf("Strict mode: property '%s' is marked readOnly in the schema", + name), + HowToFix: fmt.Sprintf("Remove the readOnly annotation from '%s' in the schema, "+ + "remove it from the request, or add '%s' to StrictIgnorePaths", name, path), + RequestPath: requestPath, + RequestMethod: requestMethod, + ParameterName: name, + Context: truncateForContext(value), + SpecLine: specLine, + SpecCol: specCol, + } +} + +// WriteOnlyPropertyError creates a ValidationError for a writeOnly property in a response. +func WriteOnlyPropertyError( + path string, + name string, + value any, + requestPath string, + requestMethod string, + specLine int, + specCol int, +) *ValidationError { + return &ValidationError{ + ValidationType: StrictValidationType, + ValidationSubType: StrictSubTypeWriteOnlyProperty, + Message: fmt.Sprintf("response property '%s' at '%s' is writeOnly and should not be returned in the response", + name, path), + Reason: fmt.Sprintf("Strict mode: property '%s' is marked writeOnly in the schema", + name), + HowToFix: fmt.Sprintf("Remove the writeOnly annotation from '%s' in the schema, "+ + "remove it from the response, or add '%s' to StrictIgnorePaths", name, path), + RequestPath: requestPath, + RequestMethod: requestMethod, + ParameterName: name, + Context: truncateForContext(value), + SpecLine: specLine, + SpecCol: specCol, + } +} + // truncateForContext creates a truncated string representation for error context. func truncateForContext(v any) string { switch val := v.(type) { diff --git a/requests/validate_request.go b/requests/validate_request.go index caaf5aa3..251b8d1e 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -332,18 +332,28 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V if !strictResult.Valid { for _, undeclared := range strictResult.UndeclaredValues { - validationErrors = append(validationErrors, - errors.UndeclaredPropertyError( - undeclared.Path, - undeclared.Name, - undeclared.Value, - undeclared.DeclaredProperties, - undeclared.Direction.String(), - request.URL.Path, - request.Method, - undeclared.SpecLine, - undeclared.SpecCol, - )) + switch undeclared.Type { + case strict.TypeReadOnlyProperty: + validationErrors = append(validationErrors, + errors.ReadOnlyPropertyError( + undeclared.Path, undeclared.Name, undeclared.Value, + request.URL.Path, request.Method, + undeclared.SpecLine, undeclared.SpecCol, + )) + default: + validationErrors = append(validationErrors, + errors.UndeclaredPropertyError( + undeclared.Path, + undeclared.Name, + undeclared.Value, + undeclared.DeclaredProperties, + undeclared.Direction.String(), + request.URL.Path, + request.Method, + undeclared.SpecLine, + undeclared.SpecCol, + )) + } } } } diff --git a/responses/validate_response.go b/responses/validate_response.go index e0d46048..f750dc9f 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -354,18 +354,28 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors if !strictResult.Valid { for _, undeclared := range strictResult.UndeclaredValues { - validationErrors = append(validationErrors, - errors.UndeclaredPropertyError( - undeclared.Path, - undeclared.Name, - undeclared.Value, - undeclared.DeclaredProperties, - undeclared.Direction.String(), - request.URL.Path, - request.Method, - undeclared.SpecLine, - undeclared.SpecCol, - )) + switch undeclared.Type { + case strict.TypeWriteOnlyProperty: + validationErrors = append(validationErrors, + errors.WriteOnlyPropertyError( + undeclared.Path, undeclared.Name, undeclared.Value, + request.URL.Path, request.Method, + undeclared.SpecLine, undeclared.SpecCol, + )) + default: + validationErrors = append(validationErrors, + errors.UndeclaredPropertyError( + undeclared.Path, + undeclared.Name, + undeclared.Value, + undeclared.DeclaredProperties, + undeclared.Direction.String(), + request.URL.Path, + request.Method, + undeclared.SpecLine, + undeclared.SpecCol, + )) + } } } } diff --git a/strict/polymorphic.go b/strict/polymorphic.go index d229f338..a425b31d 100644 --- a/strict/polymorphic.go +++ b/strict/polymorphic.go @@ -113,6 +113,10 @@ func (v *Validator) validateAllOf(ctx *traversalContext, schema *base.Schema, da // Recurse into the property propSchema := v.findPropertySchemaInAllOf(schema.AllOf, propName, allDeclared) if propSchema != nil { + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } @@ -219,6 +223,10 @@ func (v *Validator) validateVariantWithParent(ctx *traversalContext, parent *bas // Find the property schema (prefer variant, fallback to parent) propSchema := v.findPropertySchemaInMerged(variant, parent, propName, allDeclared) if propSchema != nil { + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } @@ -293,6 +301,10 @@ func (v *Validator) recurseIntoDeclaredPropertiesWithMerged(ctx *traversalContex propSchema := v.findPropertySchemaInMerged(variant, parent, propName, declared) if propSchema != nil { + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } @@ -463,6 +475,10 @@ func (v *Validator) recurseIntoAllOfDeclaredProperties(ctx *traversalContext, al propSchema := v.findPropertySchemaInAllOf(allOf, propName, declared) if propSchema != nil { + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } diff --git a/strict/property_collector.go b/strict/property_collector.go index 24317b83..b2ff86a9 100644 --- a/strict/property_collector.go +++ b/strict/property_collector.go @@ -147,6 +147,26 @@ func getPropertySchema(name string, declared map[string]*declaredProperty) *base return nil } +// checkReadWriteOnlyViolation checks if a property violates readOnly/writeOnly rules +// when the corresponding rejection flag is enabled. Returns a violation and true if so. +func (v *Validator) checkReadWriteOnlyViolation( + path string, name string, value any, + schema *base.Schema, direction Direction, +) (UndeclaredValue, bool) { + if schema == nil || v.options == nil { + return UndeclaredValue{}, false + } + if direction == DirectionRequest && v.options.StrictRejectReadOnly && + schema.ReadOnly != nil && *schema.ReadOnly { + return newReadWriteOnlyViolation(path, name, value, direction, schema), true + } + if direction == DirectionResponse && v.options.StrictRejectWriteOnly && + schema.WriteOnly != nil && *schema.WriteOnly { + return newReadWriteOnlyViolation(path, name, value, direction, schema), true + } + return UndeclaredValue{}, false +} + // shouldSkipProperty checks if a property should be skipped based on // readOnly/writeOnly and the current validation direction. func (v *Validator) shouldSkipProperty(schema *base.Schema, direction Direction) bool { diff --git a/strict/schema_walker.go b/strict/schema_walker.go index 042a91cc..6bfa084f 100644 --- a/strict/schema_walker.go +++ b/strict/schema_walker.go @@ -90,7 +90,10 @@ func (v *Validator) validateObject(ctx *traversalContext, schema *base.Schema, d if propProxy != nil { propSchema := propProxy.Schema() if propSchema != nil { - // check readOnly/writeOnly + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } @@ -191,6 +194,10 @@ func (v *Validator) recurseIntoDeclaredProperties(ctx *traversalContext, schema propSchema := propProxy.Schema() if propSchema != nil { + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } @@ -222,6 +229,10 @@ func (v *Validator) recurseIntoDeclaredProperties(ctx *traversalContext, schema propSchema := propProxy.Schema() if propSchema != nil { + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } diff --git a/strict/types.go b/strict/types.go index 17842561..045fbee2 100644 --- a/strict/types.go +++ b/strict/types.go @@ -62,6 +62,13 @@ func (d Direction) String() string { return "request" } +// Type constants for UndeclaredValue.Type, defined here for use in the +// request/response dispatch switch (existing types use inline strings). +const ( + TypeReadOnlyProperty = "readonly" + TypeWriteOnlyProperty = "writeonly" +) + // UndeclaredValue represents a value found in data that is not declared // in the schema. This is the core output of strict validation. type UndeclaredValue struct { @@ -77,7 +84,7 @@ type UndeclaredValue struct { Value any // Type indicates what kind of value this is. - // one of: "property", "header", "query", "cookie", "item" + // one of: "property", "header", "query", "cookie", "item", "readonly", "writeonly" Type string // DeclaredProperties lists property names that ARE declared at this @@ -127,6 +134,24 @@ func newUndeclaredProperty(path, name string, value any, declaredNames []string, } } +// newReadWriteOnlyViolation creates an UndeclaredValue for a readOnly/writeOnly violation. +func newReadWriteOnlyViolation(path, name string, value any, direction Direction, schema *base.Schema) UndeclaredValue { + line, col := extractSchemaLocation(schema) + violationType := TypeReadOnlyProperty + if direction == DirectionResponse { + violationType = TypeWriteOnlyProperty + } + return UndeclaredValue{ + Path: path, + Name: name, + Value: TruncateValue(value), + Type: violationType, + Direction: direction, + SpecLine: line, + SpecCol: col, + } +} + // newUndeclaredParam creates an UndeclaredValue for an undeclared parameter (query/header/cookie). // note: parameters don't have SpecLine/SpecCol because they're defined in OpenAPI parameter objects, // not schema objects. the parameter itself is the issue, not a schema definition. From 13e2498aa2b1775ef9f99491215f1e834d51cdbf Mon Sep 17 00:00:00 2001 From: Stefan Selent Date: Thu, 9 Apr 2026 21:58:55 -0700 Subject: [PATCH 2/3] Add tests for opt-in readOnly/writeOnly rejection in strict mode Tests for https://github.com/pb33f/libopenapi-validator/issues/90 - 10 strict-level tests: rejection on/off, direction-specific, nested, allOf, combined flags - 2 error constructor tests for ReadOnlyPropertyError/WriteOnlyPropertyError - 2 integration tests for request/response dispatch --- errors/strict_errors_test.go | 52 ++++ strict/validator_test.go | 483 +++++++++++++++++++++++++++++++++++ validator_test.go | 111 ++++++++ 3 files changed, 646 insertions(+) diff --git a/errors/strict_errors_test.go b/errors/strict_errors_test.go index 160ef1f2..127fd825 100644 --- a/errors/strict_errors_test.go +++ b/errors/strict_errors_test.go @@ -167,6 +167,58 @@ func TestUndeclaredCookieError(t *testing.T) { assert.Equal(t, "tracking", err.ParameterName) } +func TestReadOnlyPropertyError(t *testing.T) { + err := ReadOnlyPropertyError( + "$.body.id", + "id", + "user-123", + "/users", + "POST", + 10, + 5, + ) + + assert.NotNil(t, err) + assert.Equal(t, StrictValidationType, err.ValidationType) + assert.Equal(t, StrictSubTypeReadOnlyProperty, err.ValidationSubType) + assert.Contains(t, err.Message, "readOnly") + assert.Contains(t, err.Message, "'id'") + assert.Contains(t, err.Message, "'$.body.id'") + assert.Contains(t, err.Reason, "readOnly") + assert.Contains(t, err.HowToFix, "id") + assert.Equal(t, "/users", err.RequestPath) + assert.Equal(t, "POST", err.RequestMethod) + assert.Equal(t, "id", err.ParameterName) + assert.Equal(t, 10, err.SpecLine) + assert.Equal(t, 5, err.SpecCol) +} + +func TestWriteOnlyPropertyError(t *testing.T) { + err := WriteOnlyPropertyError( + "$.body.password", + "password", + "secret", + "/users/123", + "GET", + 20, + 3, + ) + + assert.NotNil(t, err) + assert.Equal(t, StrictValidationType, err.ValidationType) + assert.Equal(t, StrictSubTypeWriteOnlyProperty, err.ValidationSubType) + assert.Contains(t, err.Message, "writeOnly") + assert.Contains(t, err.Message, "'password'") + assert.Contains(t, err.Message, "'$.body.password'") + assert.Contains(t, err.Reason, "writeOnly") + assert.Contains(t, err.HowToFix, "password") + assert.Equal(t, "/users/123", err.RequestPath) + assert.Equal(t, "GET", err.RequestMethod) + assert.Equal(t, "password", err.ParameterName) + assert.Equal(t, 20, err.SpecLine) + assert.Equal(t, 3, err.SpecCol) +} + func TestTruncateForContext_String(t *testing.T) { // Short string should not be truncated short := truncateForContext("short") diff --git a/strict/validator_test.go b/strict/validator_test.go index c4abfaba..b843fd48 100644 --- a/strict/validator_test.go +++ b/strict/validator_test.go @@ -6183,3 +6183,486 @@ components: // Location should come from parent (not crash due to nil variant.GoLow()) assert.Greater(t, result[0].SpecLine, 0) } + +// ============== readOnly/writeOnly rejection tests ============== + +func TestStrictValidator_RejectReadOnly_InRequest(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + id: + type: string + readOnly: true + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + // readOnly property "id" sent in a request should be rejected + data := map[string]any{ + "id": "user-123", + "name": "John", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "id", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.id", result.UndeclaredValues[0].Path) + assert.Equal(t, TypeReadOnlyProperty, result.UndeclaredValues[0].Type) + assert.Equal(t, DirectionRequest, result.UndeclaredValues[0].Direction) +} + +func TestStrictValidator_RejectReadOnly_Disabled(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + id: + type: string + readOnly: true + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + // Flag off — backward compat: no violation + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "id": "user-123", + "name": "John", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RejectReadOnly_InResponse_NoEffect(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + id: + type: string + readOnly: true + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + // readOnly in response is fine — readOnly only applies to requests + data := map[string]any{ + "id": "user-123", + "name": "John", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RejectReadOnly_NestedObject(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + metadata: + type: object + properties: + createdAt: + type: string + readOnly: true + updatedBy: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "name": "John", + "metadata": map[string]any{ + "createdAt": "2024-01-01", + "updatedBy": "admin", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "createdAt", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.metadata.createdAt", result.UndeclaredValues[0].Path) + assert.Equal(t, TypeReadOnlyProperty, result.UndeclaredValues[0].Type) +} + +func TestStrictValidator_RejectReadOnly_AllOf(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Combined: + allOf: + - type: object + properties: + id: + type: string + readOnly: true + - type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Combined") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "id": "combined-1", + "name": "Test", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "id", result.UndeclaredValues[0].Name) + assert.Equal(t, TypeReadOnlyProperty, result.UndeclaredValues[0].Type) +} + +func TestStrictValidator_RejectWriteOnly_InResponse(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + password: + type: string + writeOnly: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectWriteOnly()) + v := NewValidator(opts, 3.1) + + // writeOnly property "password" in response should be rejected + data := map[string]any{ + "name": "John", + "password": "secret", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "password", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.password", result.UndeclaredValues[0].Path) + assert.Equal(t, TypeWriteOnlyProperty, result.UndeclaredValues[0].Type) + assert.Equal(t, DirectionResponse, result.UndeclaredValues[0].Direction) +} + +func TestStrictValidator_RejectWriteOnly_Disabled(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + password: + type: string + writeOnly: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + // Flag off — backward compat: no violation + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "name": "John", + "password": "secret", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RejectWriteOnly_InRequest_NoEffect(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + password: + type: string + writeOnly: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectWriteOnly()) + v := NewValidator(opts, 3.1) + + // writeOnly in request is fine — writeOnly only applies to responses + data := map[string]any{ + "name": "John", + "password": "secret", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RejectWriteOnly_NestedObject(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + auth: + type: object + properties: + token: + type: string + writeOnly: true + provider: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectWriteOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "name": "John", + "auth": map[string]any{ + "token": "secret-token", + "provider": "google", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "token", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.auth.token", result.UndeclaredValues[0].Path) + assert.Equal(t, TypeWriteOnlyProperty, result.UndeclaredValues[0].Type) +} + +func TestStrictValidator_RejectBoth_Enabled(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + id: + type: string + readOnly: true + name: + type: string + password: + type: string + writeOnly: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + // Both flags on + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictRejectReadOnly(), + config.WithStrictRejectWriteOnly(), + ) + + // Test request: readOnly "id" should be rejected + v := NewValidator(opts, 3.1) + dataRequest := map[string]any{ + "id": "user-123", + "name": "John", + "password": "secret", + } + resultRequest := v.Validate(Input{ + Schema: schema, + Data: dataRequest, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + assert.False(t, resultRequest.Valid) + require.Len(t, resultRequest.UndeclaredValues, 1) + assert.Equal(t, "id", resultRequest.UndeclaredValues[0].Name) + assert.Equal(t, TypeReadOnlyProperty, resultRequest.UndeclaredValues[0].Type) + + // Test response: writeOnly "password" should be rejected + v2 := NewValidator(opts, 3.1) + dataResponse := map[string]any{ + "id": "user-123", + "name": "John", + "password": "secret", + } + resultResponse := v2.Validate(Input{ + Schema: schema, + Data: dataResponse, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + assert.False(t, resultResponse.Valid) + require.Len(t, resultResponse.UndeclaredValues, 1) + assert.Equal(t, "password", resultResponse.UndeclaredValues[0].Name) + assert.Equal(t, TypeWriteOnlyProperty, resultResponse.UndeclaredValues[0].Type) +} diff --git a/validator_test.go b/validator_test.go index 0c444340..cd9e46c0 100644 --- a/validator_test.go +++ b/validator_test.go @@ -2783,3 +2783,114 @@ components: assert.Empty(t, reqErrors, "no validation errors expected (uncached request)") }) } + +func TestStrictRejectReadOnly_RequestIntegration(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /users: + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: string + readOnly: true + name: + type: string + responses: + "201": + description: created` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + v, errs := NewValidator(doc, config.WithStrictMode(), config.WithStrictRejectReadOnly()) + require.Empty(t, errs) + + body := map[string]any{ + "id": "user-123", + "name": "John", + } + bodyBytes, _ := json.Marshal(body) + + request, _ := http.NewRequest(http.MethodPost, "https://example.com/users", bytes.NewBuffer(bodyBytes)) + request.Header.Set("Content-Type", "application/json") + + valid, valErrs := v.ValidateHttpRequest(request) + assert.False(t, valid) + + foundReadOnly := false + for _, vErr := range valErrs { + if vErr.ValidationType == errors.StrictValidationType && + vErr.ValidationSubType == errors.StrictSubTypeReadOnlyProperty { + foundReadOnly = true + break + } + } + assert.True(t, foundReadOnly, "should report readOnly violation") +} + +func TestStrictRejectWriteOnly_ResponseIntegration(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /users/{id}: + get: + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + properties: + name: + type: string + password: + type: string + writeOnly: true` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + v, errs := NewValidator(doc, config.WithStrictMode(), config.WithStrictRejectWriteOnly()) + require.Empty(t, errs) + + request, _ := http.NewRequest(http.MethodGet, "https://example.com/users/123", http.NoBody) + + body := map[string]any{ + "name": "John", + "password": "secret", + } + bodyBytes, _ := json.Marshal(body) + + response := &http.Response{ + StatusCode: 200, + Header: http.Header{ + "Content-Type": {"application/json"}, + }, + Body: io.NopCloser(bytes.NewBuffer(bodyBytes)), + } + + valid, valErrs := v.ValidateHttpResponse(request, response) + assert.False(t, valid) + + foundWriteOnly := false + for _, vErr := range valErrs { + if vErr.ValidationType == errors.StrictValidationType && + vErr.ValidationSubType == errors.StrictSubTypeWriteOnlyProperty { + foundWriteOnly = true + break + } + } + assert.True(t, foundWriteOnly, "should report writeOnly violation") +} From 0ea4d3f45568cdbc38fb41519952cb3cfa45073d Mon Sep 17 00:00:00 2001 From: Stefan Selent Date: Thu, 9 Apr 2026 22:16:18 -0700 Subject: [PATCH 3/3] Add coverage tests for readOnly/writeOnly rejection paths Cover checkReadWriteOnlyViolation calls in: - recurseIntoDeclaredProperties (explicit + pattern properties) - validateVariantWithParent (oneOf) - recurseIntoDeclaredPropertiesWithMerged (oneOf + additionalProperties: false) - recurseIntoAllOfDeclaredProperties (allOf + additionalProperties: false) - WithStrictRejectReadOnly/WithStrictRejectWriteOnly option functions - WithExistingOpts copy of new fields --- config/config_test.go | 14 +++ requests/validate_body_test.go | 44 +++++++ responses/validate_body_test.go | 44 +++++++ strict/validator_test.go | 211 ++++++++++++++++++++++++++++++++ validator_test.go | 4 +- 5 files changed, 315 insertions(+), 2 deletions(-) diff --git a/config/config_test.go b/config/config_test.go index a9ad8442..d2f77ddd 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -466,6 +466,8 @@ func TestWithLogger(t *testing.T) { func TestWithExistingOpts_StrictFields(t *testing.T) { original := &ValidationOptions{ StrictMode: true, + StrictRejectReadOnly: true, + StrictRejectWriteOnly: true, StrictIgnorePaths: []string{"$.body.*"}, StrictIgnoredHeaders: []string{"x-custom"}, strictIgnoredHeadersMerge: true, @@ -475,12 +477,24 @@ func TestWithExistingOpts_StrictFields(t *testing.T) { opts := NewValidationOptions(WithExistingOpts(original)) assert.True(t, opts.StrictMode) + assert.True(t, opts.StrictRejectReadOnly) + assert.True(t, opts.StrictRejectWriteOnly) assert.Equal(t, original.StrictIgnorePaths, opts.StrictIgnorePaths) assert.Equal(t, original.StrictIgnoredHeaders, opts.StrictIgnoredHeaders) assert.True(t, opts.strictIgnoredHeadersMerge) assert.Equal(t, original.Logger, opts.Logger) } +func TestWithStrictRejectReadOnly(t *testing.T) { + opts := NewValidationOptions(WithStrictRejectReadOnly()) + assert.True(t, opts.StrictRejectReadOnly) +} + +func TestWithStrictRejectWriteOnly(t *testing.T) { + opts := NewValidationOptions(WithStrictRejectWriteOnly()) + assert.True(t, opts.StrictRejectWriteOnly) +} + func TestStrictModeWithIgnorePaths(t *testing.T) { paths := []string{"$.body.metadata.*"} opts := NewValidationOptions( diff --git a/requests/validate_body_test.go b/requests/validate_body_test.go index ff909590..ed41101f 100644 --- a/requests/validate_body_test.go +++ b/requests/validate_body_test.go @@ -1614,6 +1614,50 @@ paths: assert.Len(t, errors, 0) } +func TestValidateBody_StrictMode_ReadOnlyProperty(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /users: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: string + readOnly: true + name: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + v := NewRequestBodyValidator(&m.Model, + config.WithStrictMode(), + config.WithStrictRejectReadOnly(), + ) + + body := map[string]interface{}{ + "id": "user-123", + "name": "John", + } + + bodyBytes, _ := json.Marshal(body) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/users", + bytes.NewBuffer(bodyBytes)) + request.Header.Set("Content-Type", "application/json") + + valid, errors := v.ValidateRequestBody(request) + + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].Message, "readOnly") + assert.Contains(t, errors[0].Message, "id") +} + func TestValidateRequestBody_XMLMarshalError(t *testing.T) { spec := []byte(` openapi: 3.1.0 diff --git a/responses/validate_body_test.go b/responses/validate_body_test.go index aa564967..1a0a51ab 100644 --- a/responses/validate_body_test.go +++ b/responses/validate_body_test.go @@ -1619,6 +1619,50 @@ paths: assert.Len(t, errs, 0) } +func TestValidateBody_StrictMode_WriteOnlyProperty(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /users/123: + get: + responses: + '200': + content: + application/json: + schema: + type: object + properties: + name: + type: string + password: + type: string + writeOnly: true` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + v := NewResponseBodyValidator(&m.Model, + config.WithStrictMode(), + config.WithStrictRejectWriteOnly(), + ) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/users/123", nil) + + responseBody := `{"name": "John", "password": "secret"}` + response := &http.Response{ + Header: http.Header{}, + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(responseBody)), + } + response.Header.Set("Content-Type", "application/json") + + valid, errs := v.ValidateResponseBody(request, response) + + assert.False(t, valid) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Message, "writeOnly") + assert.Contains(t, errs[0].Message, "password") +} + func TestValidateBody_ValidURLEncodedBody(t *testing.T) { spec := `openapi: 3.1.0 paths: diff --git a/strict/validator_test.go b/strict/validator_test.go index b843fd48..6e88b432 100644 --- a/strict/validator_test.go +++ b/strict/validator_test.go @@ -6666,3 +6666,214 @@ components: assert.Equal(t, "password", resultResponse.UndeclaredValues[0].Name) assert.Equal(t, TypeWriteOnlyProperty, resultResponse.UndeclaredValues[0].Type) } + +func TestStrictValidator_RejectReadOnly_AdditionalPropertiesFalse(t *testing.T) { + // Covers schema_walker.go recurseIntoDeclaredProperties: + // explicit property path and patternProperties path + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Strict: + type: object + additionalProperties: false + properties: + id: + type: string + readOnly: true + name: + type: string + patternProperties: + "^x-": + type: string + readOnly: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Strict") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "id": "user-1", + "name": "John", + "x-custom": "value", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 2) + + names := []string{result.UndeclaredValues[0].Name, result.UndeclaredValues[1].Name} + assert.Contains(t, names, "id") + assert.Contains(t, names, "x-custom") + for _, uv := range result.UndeclaredValues { + assert.Equal(t, TypeReadOnlyProperty, uv.Type) + } +} + +func TestStrictValidator_RejectReadOnly_OneOf(t *testing.T) { + // Covers polymorphic.go validateVariantWithParent path + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + OneOfSchema: + type: object + oneOf: + - type: object + properties: + id: + type: string + readOnly: true + email: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "OneOfSchema") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "id": "user-1", + "email": "test@example.com", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "id", result.UndeclaredValues[0].Name) + assert.Equal(t, TypeReadOnlyProperty, result.UndeclaredValues[0].Type) +} + +func TestStrictValidator_RejectReadOnly_OneOfAdditionalPropertiesFalse(t *testing.T) { + // Covers polymorphic.go recurseIntoDeclaredPropertiesWithMerged path + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + OneOfStrict: + type: object + additionalProperties: false + properties: + name: + type: string + oneOf: + - type: object + additionalProperties: false + properties: + name: + type: string + id: + type: string + readOnly: true + data: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "OneOfStrict") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "name": "test", + "id": "should-be-rejected", + "data": "valid", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "id", result.UndeclaredValues[0].Name) + assert.Equal(t, TypeReadOnlyProperty, result.UndeclaredValues[0].Type) +} + +func TestStrictValidator_RejectReadOnly_AllOfAdditionalPropertiesFalse(t *testing.T) { + // Covers polymorphic.go recurseIntoAllOfDeclaredProperties path + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + AllOfStrict: + type: object + additionalProperties: false + allOf: + - type: object + properties: + id: + type: string + readOnly: true + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "AllOfStrict") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "id": "should-be-rejected", + "name": "John", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "id", result.UndeclaredValues[0].Name) + assert.Equal(t, TypeReadOnlyProperty, result.UndeclaredValues[0].Type) +} + +func TestStrictValidator_CheckReadWriteOnlyViolation_NilSchema(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + _, ok := v.checkReadWriteOnlyViolation("$.body.x", "x", "val", nil, DirectionRequest) + assert.False(t, ok) +} diff --git a/validator_test.go b/validator_test.go index cd9e46c0..126bb631 100644 --- a/validator_test.go +++ b/validator_test.go @@ -2784,7 +2784,7 @@ components: }) } -func TestStrictRejectReadOnly_RequestIntegration(t *testing.T) { +func TestStrictMode_RejectReadOnly_RequestIntegration(t *testing.T) { spec := `openapi: 3.1.0 paths: /users: @@ -2834,7 +2834,7 @@ paths: assert.True(t, foundReadOnly, "should report readOnly violation") } -func TestStrictRejectWriteOnly_ResponseIntegration(t *testing.T) { +func TestStrictMode_RejectWriteOnly_ResponseIntegration(t *testing.T) { spec := `openapi: 3.1.0 paths: /users/{id}: