From 2747162ecc2f285fd70c5f45379693dc427c1033 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Thu, 22 Oct 2026 18:22:40 -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 against the spec as role: user but binds as Role: "admin", bypassing validation (oapi-codegen/oapi-codegen#2605). The fix there is for strict servers to bind the value that was actually validated. kin-openapi's openapi3filter.Options.KeepValidatedValue leaves that value on the request body: after validation, req.Body stays readable and also implements ValidatedValue() any. Since this middleware validates c.Request directly, handlers see that body. Enable the flag by default, including for OapiRequestValidator(spec) and ValidateRequestFromContext with nil options, so strict servers get the protection without any configuration. The caller's openapi3filter.Options is copied, not mutated. Add Options.DiscardValidatedValue as the opt-out, e.g. to limit memory retention for very large bodies; it overrides Options.Options.KeepValidatedValue. Middleware registered after the validator that replaces the request body (decompression, size limiting, logging wrappers) hides the validated value, so the README now says such middleware belongs before the validator. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++++ oapi_validate.go | 18 +++++++- oapi_validate_test.go | 98 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 35121d2..feb659f 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,12 @@ Middleware for the [Gin web server](https://github.com/gin-gonic/gin) for use with [oapi-codegen/oapi-codegen](https://github.com/oapi-codegen/oapi-codegen). +## Validated request body + +The middleware enables kin-openapi's `openapi3filter.Options.KeepValidatedValue` by default. After a request body passes validation, `c.Request.Body` stays readable (with the original bytes, unless kin-openapi rewrote it to apply schema defaults) and additionally implements `interface{ ValidatedValue() any }`, returning the decoded value that was validated. oapi-codegen strict servers that support it can bind that value instead of decoding the body a second time, so the handler sees exactly what was validated (for example, `encoding/json`'s case-insensitive member matching can no longer make a request validate as one value but bind as another). + +To opt out, for instance to limit memory retention for very large request bodies, set `Options.DiscardValidatedValue = true`. This setting overrides `Options.Options.KeepValidatedValue`. + +Any middleware placed after the validator that replaces `c.Request.Body` (decompression, size limiting, logging wrappers and the like) hides the validated value from handlers. Register such middleware before the validator. + Licensed under the Apache-2.0. diff --git a/oapi_validate.go b/oapi_validate.go index 871d8ff..f1fd26f 100644 --- a/oapi_validate.go +++ b/oapi_validate.go @@ -73,6 +73,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 @@ -140,9 +150,15 @@ func ValidateRequestFromContext(c *gin.Context, router routers.Router, options * requestContext := context.WithValue(context.Background(), GinContextKey, c) //nolint:staticcheck if options != nil { - validationInput.Options = &options.Options + // Copy the openapi3filter options so the caller's struct is not + // mutated when applying our KeepValidatedValue default. + filterOptions := options.Options + filterOptions.KeepValidatedValue = !options.DiscardValidatedValue + validationInput.Options = &filterOptions validationInput.ParamDecoder = options.ParamDecoder requestContext = context.WithValue(requestContext, UserDataKey, options.UserData) //nolint:staticcheck + } else { + validationInput.Options = &openapi3filter.Options{KeepValidatedValue: true} } err = openapi3filter.ValidateRequest(requestContext, validationInput) diff --git a/oapi_validate_test.go b/oapi_validate_test.go index 5832286..9b4eb3c 100644 --- a/oapi_validate_test.go +++ b/oapi_validate_test.go @@ -439,3 +439,101 @@ 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 *Options + wantValidated bool + }{ + { + name: "nil options keep the validated value", + options: nil, + wantValidated: true, + }, + { + name: "empty options keep the validated value", + options: &Options{SilenceServersWarning: true}, + wantValidated: true, + }, + { + name: "DiscardValidatedValue drops the validated value", + options: &Options{ + SilenceServersWarning: true, + DiscardValidatedValue: true, + // DiscardValidatedValue overrides the openapi3filter setting. + Options: openapi3filter.Options{KeepValidatedValue: true}, + }, + wantValidated: false, + }, + { + name: "ExcludeRequestBody skips body validation", + options: &Options{ + SilenceServersWarning: true, + Options: openapi3filter.Options{ExcludeRequestBody: true}, + }, + wantValidated: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var originalFilterOptions openapi3filter.Options + if tt.options != nil { + originalFilterOptions = tt.options.Options + } + + g := gin.New() + if tt.options == nil { + g.Use(OapiRequestValidator(swagger)) + } else { + g.Use(OapiRequestValidatorWithOptions(swagger, tt.options)) + } + + called := false + var hasValidated bool + var validated any + var bodyBytes []byte + var bound map[string]any + g.POST("/resource", func(c *gin.Context) { + called = true + if vv, ok := c.Request.Body.(validatedValuer); ok { + hasValidated = true + validated = vv.ValidatedValue() + } + var readErr error + bodyBytes, readErr = io.ReadAll(c.Request.Body) + require.NoError(t, readErr) + require.NoError(t, json.Unmarshal(bodyBytes, &bound)) + c.AbortWithStatus(http.StatusNoContent) + }) + + body := map[string]any{"name": "Marcin"} + rec := doPost(t, g, "http://deepmap.ai/resource", body) + assert.Equal(t, http.StatusNoContent, rec.Code) + require.True(t, called, "Handler should have been called") + + wantBytes, err := json.Marshal(body) + require.NoError(t, err) + assert.Equal(t, string(wantBytes), string(bodyBytes), "body should still be readable with the original bytes") + assert.Equal(t, body, bound) + + assert.Equal(t, tt.wantValidated, hasValidated) + if tt.wantValidated { + assert.Equal(t, map[string]any{"name": "Marcin"}, validated) + } + + if tt.options != nil { + assert.Equal(t, originalFilterOptions.KeepValidatedValue, tt.options.Options.KeepValidatedValue, + "the caller's openapi3filter.Options must not be mutated") + } + }) + } +}