From 947ef39725a244359356881757b576ee4f05b1d4 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Thu, 22 Oct 2026 18:22:35 -0700 Subject: [PATCH] Keep the validated request body value by default oapi-codegen strict servers decode the request body a second time after this middleware has validated it. Because encoding/json matches object members case-insensitively, a body such as {"role":"user","ROLE":"admin"} validates as role: user but binds as Role: "admin", bypassing validation. oapi-codegen/oapi-codegen#2605 changes strict servers to bind the value that was validated instead, which kin-openapi exposes when openapi3filter.Options.KeepValidatedValue is set: the request body gains a ValidatedValue() any method returning the decoded, validated value, while remaining readable with its original bytes. For that fix to take effect without extra configuration, the middleware now enables KeepValidatedValue by default, both for OapiRequestValidator(spec) and when Options are supplied. The caller's openapi3filter.Options is copied rather than mutated. The new Options.DiscardValidatedValue opts out, for example to limit memory retention for very large request bodies, and overrides Options.Options.KeepValidatedValue. Since the middleware validates ctx.Request() in place, any middleware registered after it that replaces the request body (decompression, size limiting, logging wrappers) hides the validated value from handlers. The README documents that such middleware belongs before the validator. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++++ oapi_validate.go | 19 ++++++++- oapi_validate_test.go | 99 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e161d5..4f749d6 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,11 @@ Middleware for the [Iris web server](https://github.com/kataras/iris/) for use with [oapi-codegen](https://github.com/oapi-codegen/oapi-codegen). Licensed under the Apache-2.0. + +## Validated request body + +The middleware enables kin-openapi's `openapi3filter.Options.KeepValidatedValue` by default. After a request body passes validation, the body that handlers see (`ctx.Request().Body`) exposes the decoded value that was validated through a `ValidatedValue() any` method, and remains readable with its original bytes. An [oapi-codegen](https://github.com/oapi-codegen/oapi-codegen) strict server can bind that value instead of decoding the body a second time, so the handler receives exactly what was validated. + +To turn this off, for instance to limit memory retention for very large request bodies, set `DiscardValidatedValue: true` in `Options`. This setting overrides `Options.Options.KeepValidatedValue`. + +Middleware that replaces the request body after the validator has run (decompression, size limiting, logging wrappers and the like) hides the validated value from handlers. Register such middleware before the validator. diff --git a/oapi_validate.go b/oapi_validate.go index 552593e..a30d18e 100644 --- a/oapi_validate.go +++ b/oapi_validate.go @@ -59,6 +59,16 @@ type Options struct { MultiErrorHandler MultiErrorHandler // SilenceServersWarning allows silencing a warning for https://github.com/deepmap/oapi-codegen/issues/882 that reports when an OpenAPI spec has `spec.Servers != nil` SilenceServersWarning bool + // DiscardValidatedValue turns off openapi3filter.Options.KeepValidatedValue, + // which this middleware enables by default. With it enabled, the request body + // seen by handlers exposes the value that was validated through a + // ValidatedValue() method, so a handler (for example an oapi-codegen strict + // server) can bind that value instead of decoding the body a second time. + // Set this to drop the decoded value after validation, for instance to limit + // memory retention for very large request bodies. + // + // Options.Options.KeepValidatedValue is overridden by this setting. + DiscardValidatedValue bool } // OapiRequestValidatorWithOptions creates a validator from a swagger object, with validation options @@ -112,9 +122,16 @@ func ValidateRequestFromContext(ctx iris.Context, router routers.Router, options requestContext := context.WithValue(context.Background(), IrisContextKey, ctx) //nolint:staticcheck if options != nil { - requestValidationInput.Options = &options.Options + // Copy the caller's openapi3filter options so that setting + // KeepValidatedValue does not mutate the caller's struct. + filterOptions := options.Options + filterOptions.KeepValidatedValue = !options.DiscardValidatedValue + requestValidationInput.Options = &filterOptions requestValidationInput.ParamDecoder = options.ParamDecoder requestContext = context.WithValue(requestContext, UserDataKey, options.UserData) //nolint:staticcheck + } else { + // No options were given: keep the validated body value by default. + requestValidationInput.Options = &openapi3filter.Options{KeepValidatedValue: true} } err = openapi3filter.ValidateRequest(requestContext, requestValidationInput) diff --git a/oapi_validate_test.go b/oapi_validate_test.go index c3f7ebd..2e671d0 100644 --- a/oapi_validate_test.go +++ b/oapi_validate_test.go @@ -443,3 +443,102 @@ func TestOapiRequestValidatorWithOptionsMultiErrorAndCustomHandler(t *testing.T) called = false } } + +func TestOapiRequestValidatorKeepValidatedValue(t *testing.T) { + swagger, err := openapi3.NewLoader().LoadFromData(testSchema) + require.NoError(t, err, "Error initializing swagger") + + type validatedValuer interface { + ValidatedValue() any + } + + tests := []struct { + name string + // options is nil to exercise OapiRequestValidator(spec). + options *Options + // wantValidatedValue is whether the handler should see a request body + // exposing ValidatedValue(). + wantValidatedValue bool + }{ + { + name: "no options", + options: nil, + wantValidatedValue: true, + }, + { + name: "empty options", + options: &Options{}, + wantValidatedValue: true, + }, + { + name: "discard validated value", + options: &Options{DiscardValidatedValue: true}, + wantValidatedValue: false, + }, + { + name: "exclude request body", + options: &Options{ + Options: openapi3filter.Options{ExcludeRequestBody: true}, + }, + // The body is not validated, so there is no validated value. + wantValidatedValue: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + i := iris.New() + if tc.options == nil { + i.Use(OapiRequestValidator(swagger)) + } else { + i.Use(OapiRequestValidatorWithOptions(swagger, tc.options)) + } + + called := false + var ( + isValidatedValuer bool + validatedValue any + bodyBytes []byte + readErr error + ) + i.Post("/resource", func(ctx iris.Context) { + called = true + body := ctx.Request().Body + var vv validatedValuer + vv, isValidatedValuer = body.(validatedValuer) + if isValidatedValuer { + validatedValue = vv.ValidatedValue() + } + bodyBytes, readErr = io.ReadAll(body) + ctx.StatusCode(http.StatusNoContent) + }) + + require.NoError(t, i.Build()) + + reqBody := struct { + Name string `json:"name"` + }{ + Name: "Marcin", + } + res := doPost(t, i, "http://deepmap.ai/resource", reqBody) + assert.Equal(t, http.StatusNoContent, res.Code) + require.True(t, called, "Handler should have been called") + + // The body must remain readable with the original bytes. + require.NoError(t, readErr) + assert.Equal(t, `{"name":"Marcin"}`, string(bodyBytes)) + + assert.Equal(t, tc.wantValidatedValue, isValidatedValuer, + "request body ValidatedValue() presence") + if tc.wantValidatedValue { + assert.Equal(t, map[string]any{"name": "Marcin"}, validatedValue) + } + + // The caller's openapi3filter options must not be mutated. + if tc.options != nil { + assert.False(t, tc.options.Options.KeepValidatedValue, + "caller's openapi3filter.Options should not be modified") + } + }) + } +}