Skip to content

Commit d6a52f6

Browse files
byteddaveshanley
authored andcommitted
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 #90
1 parent 4934fdd commit d6a52f6

8 files changed

Lines changed: 203 additions & 31 deletions

File tree

config/config.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ type ValidationOptions struct {
4242
StrictIgnorePaths []string // Instance JSONPath patterns to exclude from strict checks
4343
StrictIgnoredHeaders []string // Headers to always ignore in strict mode (nil = use defaults)
4444
strictIgnoredHeadersMerge bool // Internal: true if merging with defaults
45+
StrictRejectReadOnly bool // Reject readOnly properties in requests
46+
StrictRejectWriteOnly bool // Reject writeOnly properties in responses
4547
}
4648

4749
// Option Enables an 'Options pattern' approach
@@ -88,6 +90,8 @@ func WithExistingOpts(options *ValidationOptions) Option {
8890
o.StrictIgnorePaths = options.StrictIgnorePaths
8991
o.StrictIgnoredHeaders = options.StrictIgnoredHeaders
9092
o.strictIgnoredHeadersMerge = options.strictIgnoredHeadersMerge
93+
o.StrictRejectReadOnly = options.StrictRejectReadOnly
94+
o.StrictRejectWriteOnly = options.StrictRejectWriteOnly
9195
}
9296
}
9397
}
@@ -241,6 +245,24 @@ func WithStrictIgnorePaths(paths ...string) Option {
241245
}
242246
}
243247

248+
// WithStrictRejectReadOnly enables rejection of readOnly properties in requests.
249+
// When enabled, readOnly properties present in request bodies are reported as
250+
// validation errors instead of being silently skipped.
251+
func WithStrictRejectReadOnly() Option {
252+
return func(o *ValidationOptions) {
253+
o.StrictRejectReadOnly = true
254+
}
255+
}
256+
257+
// WithStrictRejectWriteOnly enables rejection of writeOnly properties in responses.
258+
// When enabled, writeOnly properties present in response bodies are reported as
259+
// validation errors instead of being silently skipped.
260+
func WithStrictRejectWriteOnly() Option {
261+
return func(o *ValidationOptions) {
262+
o.StrictRejectWriteOnly = true
263+
}
264+
}
265+
244266
// WithStrictIgnoredHeaders replaces the default ignored headers list entirely.
245267
// Use this to fully control which headers are ignored in strict mode.
246268
// For the default list, see the strict package's DefaultIgnoredHeaders.

errors/strict_errors.go

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ import (
1111
// StrictValidationType is the validation type for strict mode errors.
1212
const StrictValidationType = "strict"
1313

14-
// StrictValidationSubTypes for different kinds of undeclared values.
14+
// StrictValidationSubTypes for different kinds of strict validation errors.
1515
const (
16-
StrictSubTypeProperty = "undeclared-property"
17-
StrictSubTypeHeader = "undeclared-header"
18-
StrictSubTypeQuery = "undeclared-query-param"
19-
StrictSubTypeCookie = "undeclared-cookie"
16+
StrictSubTypeProperty = "undeclared-property"
17+
StrictSubTypeHeader = "undeclared-header"
18+
StrictSubTypeQuery = "undeclared-query-param"
19+
StrictSubTypeCookie = "undeclared-cookie"
20+
StrictSubTypeReadOnlyProperty = "readonly-property"
21+
StrictSubTypeWriteOnlyProperty = "writeonly-property"
2022
)
2123

2224
// UndeclaredPropertyError creates a ValidationError for an undeclared property.
@@ -132,6 +134,62 @@ func UndeclaredCookieError(
132134
}
133135
}
134136

137+
// ReadOnlyPropertyError creates a ValidationError for a readOnly property in a request.
138+
func ReadOnlyPropertyError(
139+
path string,
140+
name string,
141+
value any,
142+
requestPath string,
143+
requestMethod string,
144+
specLine int,
145+
specCol int,
146+
) *ValidationError {
147+
return &ValidationError{
148+
ValidationType: StrictValidationType,
149+
ValidationSubType: StrictSubTypeReadOnlyProperty,
150+
Message: fmt.Sprintf("request property '%s' at '%s' is readOnly and should not be sent in the request",
151+
name, path),
152+
Reason: fmt.Sprintf("Strict mode: property '%s' is marked readOnly in the schema",
153+
name),
154+
HowToFix: fmt.Sprintf("Remove the readOnly annotation from '%s' in the schema, "+
155+
"remove it from the request, or add '%s' to StrictIgnorePaths", name, path),
156+
RequestPath: requestPath,
157+
RequestMethod: requestMethod,
158+
ParameterName: name,
159+
Context: truncateForContext(value),
160+
SpecLine: specLine,
161+
SpecCol: specCol,
162+
}
163+
}
164+
165+
// WriteOnlyPropertyError creates a ValidationError for a writeOnly property in a response.
166+
func WriteOnlyPropertyError(
167+
path string,
168+
name string,
169+
value any,
170+
requestPath string,
171+
requestMethod string,
172+
specLine int,
173+
specCol int,
174+
) *ValidationError {
175+
return &ValidationError{
176+
ValidationType: StrictValidationType,
177+
ValidationSubType: StrictSubTypeWriteOnlyProperty,
178+
Message: fmt.Sprintf("response property '%s' at '%s' is writeOnly and should not be returned in the response",
179+
name, path),
180+
Reason: fmt.Sprintf("Strict mode: property '%s' is marked writeOnly in the schema",
181+
name),
182+
HowToFix: fmt.Sprintf("Remove the writeOnly annotation from '%s' in the schema, "+
183+
"remove it from the response, or add '%s' to StrictIgnorePaths", name, path),
184+
RequestPath: requestPath,
185+
RequestMethod: requestMethod,
186+
ParameterName: name,
187+
Context: truncateForContext(value),
188+
SpecLine: specLine,
189+
SpecCol: specCol,
190+
}
191+
}
192+
135193
// truncateForContext creates a truncated string representation for error context.
136194
func truncateForContext(v any) string {
137195
switch val := v.(type) {

requests/validate_request.go

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -332,18 +332,28 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V
332332

333333
if !strictResult.Valid {
334334
for _, undeclared := range strictResult.UndeclaredValues {
335-
validationErrors = append(validationErrors,
336-
errors.UndeclaredPropertyError(
337-
undeclared.Path,
338-
undeclared.Name,
339-
undeclared.Value,
340-
undeclared.DeclaredProperties,
341-
undeclared.Direction.String(),
342-
request.URL.Path,
343-
request.Method,
344-
undeclared.SpecLine,
345-
undeclared.SpecCol,
346-
))
335+
switch undeclared.Type {
336+
case strict.TypeReadOnlyProperty:
337+
validationErrors = append(validationErrors,
338+
errors.ReadOnlyPropertyError(
339+
undeclared.Path, undeclared.Name, undeclared.Value,
340+
request.URL.Path, request.Method,
341+
undeclared.SpecLine, undeclared.SpecCol,
342+
))
343+
default:
344+
validationErrors = append(validationErrors,
345+
errors.UndeclaredPropertyError(
346+
undeclared.Path,
347+
undeclared.Name,
348+
undeclared.Value,
349+
undeclared.DeclaredProperties,
350+
undeclared.Direction.String(),
351+
request.URL.Path,
352+
request.Method,
353+
undeclared.SpecLine,
354+
undeclared.SpecCol,
355+
))
356+
}
347357
}
348358
}
349359
}

responses/validate_response.go

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -354,18 +354,28 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors
354354

355355
if !strictResult.Valid {
356356
for _, undeclared := range strictResult.UndeclaredValues {
357-
validationErrors = append(validationErrors,
358-
errors.UndeclaredPropertyError(
359-
undeclared.Path,
360-
undeclared.Name,
361-
undeclared.Value,
362-
undeclared.DeclaredProperties,
363-
undeclared.Direction.String(),
364-
request.URL.Path,
365-
request.Method,
366-
undeclared.SpecLine,
367-
undeclared.SpecCol,
368-
))
357+
switch undeclared.Type {
358+
case strict.TypeWriteOnlyProperty:
359+
validationErrors = append(validationErrors,
360+
errors.WriteOnlyPropertyError(
361+
undeclared.Path, undeclared.Name, undeclared.Value,
362+
request.URL.Path, request.Method,
363+
undeclared.SpecLine, undeclared.SpecCol,
364+
))
365+
default:
366+
validationErrors = append(validationErrors,
367+
errors.UndeclaredPropertyError(
368+
undeclared.Path,
369+
undeclared.Name,
370+
undeclared.Value,
371+
undeclared.DeclaredProperties,
372+
undeclared.Direction.String(),
373+
request.URL.Path,
374+
request.Method,
375+
undeclared.SpecLine,
376+
undeclared.SpecCol,
377+
))
378+
}
369379
}
370380
}
371381
}

strict/polymorphic.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ func (v *Validator) validateAllOf(ctx *traversalContext, schema *base.Schema, da
113113
// Recurse into the property
114114
propSchema := v.findPropertySchemaInAllOf(schema.AllOf, propName, allDeclared)
115115
if propSchema != nil {
116+
if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok {
117+
undeclared = append(undeclared, violation)
118+
continue
119+
}
116120
if v.shouldSkipProperty(propSchema, ctx.direction) {
117121
continue
118122
}
@@ -219,6 +223,10 @@ func (v *Validator) validateVariantWithParent(ctx *traversalContext, parent *bas
219223
// Find the property schema (prefer variant, fallback to parent)
220224
propSchema := v.findPropertySchemaInMerged(variant, parent, propName, allDeclared)
221225
if propSchema != nil {
226+
if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok {
227+
undeclared = append(undeclared, violation)
228+
continue
229+
}
222230
if v.shouldSkipProperty(propSchema, ctx.direction) {
223231
continue
224232
}
@@ -293,6 +301,10 @@ func (v *Validator) recurseIntoDeclaredPropertiesWithMerged(ctx *traversalContex
293301

294302
propSchema := v.findPropertySchemaInMerged(variant, parent, propName, declared)
295303
if propSchema != nil {
304+
if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok {
305+
undeclared = append(undeclared, violation)
306+
continue
307+
}
296308
if v.shouldSkipProperty(propSchema, ctx.direction) {
297309
continue
298310
}
@@ -463,6 +475,10 @@ func (v *Validator) recurseIntoAllOfDeclaredProperties(ctx *traversalContext, al
463475

464476
propSchema := v.findPropertySchemaInAllOf(allOf, propName, declared)
465477
if propSchema != nil {
478+
if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok {
479+
undeclared = append(undeclared, violation)
480+
continue
481+
}
466482
if v.shouldSkipProperty(propSchema, ctx.direction) {
467483
continue
468484
}

strict/property_collector.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,26 @@ func getPropertySchema(name string, declared map[string]*declaredProperty) *base
147147
return nil
148148
}
149149

150+
// checkReadWriteOnlyViolation checks if a property violates readOnly/writeOnly rules
151+
// when the corresponding rejection flag is enabled. Returns a violation and true if so.
152+
func (v *Validator) checkReadWriteOnlyViolation(
153+
path string, name string, value any,
154+
schema *base.Schema, direction Direction,
155+
) (UndeclaredValue, bool) {
156+
if schema == nil || v.options == nil {
157+
return UndeclaredValue{}, false
158+
}
159+
if direction == DirectionRequest && v.options.StrictRejectReadOnly &&
160+
schema.ReadOnly != nil && *schema.ReadOnly {
161+
return newReadWriteOnlyViolation(path, name, value, direction, schema), true
162+
}
163+
if direction == DirectionResponse && v.options.StrictRejectWriteOnly &&
164+
schema.WriteOnly != nil && *schema.WriteOnly {
165+
return newReadWriteOnlyViolation(path, name, value, direction, schema), true
166+
}
167+
return UndeclaredValue{}, false
168+
}
169+
150170
// shouldSkipProperty checks if a property should be skipped based on
151171
// readOnly/writeOnly and the current validation direction.
152172
func (v *Validator) shouldSkipProperty(schema *base.Schema, direction Direction) bool {

strict/schema_walker.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ func (v *Validator) validateObject(ctx *traversalContext, schema *base.Schema, d
9090
if propProxy != nil {
9191
propSchema := propProxy.Schema()
9292
if propSchema != nil {
93-
// check readOnly/writeOnly
93+
if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok {
94+
undeclared = append(undeclared, violation)
95+
continue
96+
}
9497
if v.shouldSkipProperty(propSchema, ctx.direction) {
9598
continue
9699
}
@@ -191,6 +194,10 @@ func (v *Validator) recurseIntoDeclaredProperties(ctx *traversalContext, schema
191194

192195
propSchema := propProxy.Schema()
193196
if propSchema != nil {
197+
if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok {
198+
undeclared = append(undeclared, violation)
199+
continue
200+
}
194201
if v.shouldSkipProperty(propSchema, ctx.direction) {
195202
continue
196203
}
@@ -222,6 +229,10 @@ func (v *Validator) recurseIntoDeclaredProperties(ctx *traversalContext, schema
222229

223230
propSchema := propProxy.Schema()
224231
if propSchema != nil {
232+
if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok {
233+
undeclared = append(undeclared, violation)
234+
continue
235+
}
225236
if v.shouldSkipProperty(propSchema, ctx.direction) {
226237
continue
227238
}

strict/types.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ func (d Direction) String() string {
6262
return "request"
6363
}
6464

65+
// Type constants for UndeclaredValue.Type, defined here for use in the
66+
// request/response dispatch switch (existing types use inline strings).
67+
const (
68+
TypeReadOnlyProperty = "readonly"
69+
TypeWriteOnlyProperty = "writeonly"
70+
)
71+
6572
// UndeclaredValue represents a value found in data that is not declared
6673
// in the schema. This is the core output of strict validation.
6774
type UndeclaredValue struct {
@@ -77,7 +84,7 @@ type UndeclaredValue struct {
7784
Value any
7885

7986
// Type indicates what kind of value this is.
80-
// one of: "property", "header", "query", "cookie", "item"
87+
// one of: "property", "header", "query", "cookie", "item", "readonly", "writeonly"
8188
Type string
8289

8390
// DeclaredProperties lists property names that ARE declared at this
@@ -127,6 +134,24 @@ func newUndeclaredProperty(path, name string, value any, declaredNames []string,
127134
}
128135
}
129136

137+
// newReadWriteOnlyViolation creates an UndeclaredValue for a readOnly/writeOnly violation.
138+
func newReadWriteOnlyViolation(path, name string, value any, direction Direction, schema *base.Schema) UndeclaredValue {
139+
line, col := extractSchemaLocation(schema)
140+
violationType := TypeReadOnlyProperty
141+
if direction == DirectionResponse {
142+
violationType = TypeWriteOnlyProperty
143+
}
144+
return UndeclaredValue{
145+
Path: path,
146+
Name: name,
147+
Value: TruncateValue(value),
148+
Type: violationType,
149+
Direction: direction,
150+
SpecLine: line,
151+
SpecCol: col,
152+
}
153+
}
154+
130155
// newUndeclaredParam creates an UndeclaredValue for an undeclared parameter (query/header/cookie).
131156
// note: parameters don't have SpecLine/SpecCol because they're defined in OpenAPI parameter objects,
132157
// not schema objects. the parameter itself is the issue, not a schema definition.

0 commit comments

Comments
 (0)