From 193f837178781fe7c8e05e90a08d7c19bc7ab871 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Thu, 22 Oct 2026 18:22:33 -0700 Subject: [PATCH] Keep the validated request body value by default Enable openapi3filter.Options.KeepValidatedValue by default. With it, openapi3filter leaves the decoded, validated request body value on req.Body behind a ValidatedValue() any method, while the body stays readable with its original bytes. oapi-codegen strict servers are being changed to bind that value (oapi-codegen/oapi-codegen#2605) instead of decoding the body a second time. Decoding twice is a validation bypass: encoding/json matches object member names case-insensitively, so {"role":"user","ROLE":"admin"} validates as role: user but binds as Role: "admin". For the fix to protect users by default, the middleware has to keep the value by default. The middleware now passes openapi3filter a copy of Options.Options with KeepValidatedValue set, leaving the caller's struct untouched; nil options (OapiRequestValidator, OapiValidatorFromYamlFile, or ValidateRequestFromContext called with nil) also keep the value. The new Options.DiscardValidatedValue opts out, for instance to limit memory retention for very large request bodies, and overrides Options.Options.KeepValidatedValue. Any middleware placed after the validator that replaces the request body (decompression, size limiting, logging wrappers) hides the validated value from handlers, so such middleware belongs before the validator. The README documents the default, the opt-out and this ordering caveat. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++ oapi_validate.go | 25 +++++++++++++- oapi_validate_test.go | 77 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8056925..8717cbf 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ e.Use(mw) // now all HTTP routes will be handled by the middleware, and any requests that are invalid will be rejected ``` +### Validated request body + +The middleware enables kin-openapi's [`openapi3filter.Options.KeepValidatedValue`](https://pkg.go.dev/github.com/getkin/kin-openapi/openapi3filter#Options) by default. After a request body passes validation, the body that handlers see (`c.Request().Body`) also has a `ValidatedValue() any` method returning the decoded value that was validated, and it can still be read as usual. This lets a handler, such as an `oapi-codegen` strict server, bind exactly the value that was validated rather than decoding the body a second time, which could interpret it differently (for example, `encoding/json` matches object member names case-insensitively, so `{"role":"user","ROLE":"admin"}` would validate as `role: user` but decode as `Role: "admin"`). To drop the decoded value after validation instead, for instance to limit memory retention for very large request bodies, set `Options.DiscardValidatedValue`; it overrides `Options.Options.KeepValidatedValue`. + +Any middleware that runs after the validator and replaces the request body (such as decompression, request size limiting or logging wrappers) hides the validated value from handlers, so register such middleware before the validator. + ## FAQs ### What versions of Echo does this support? diff --git a/oapi_validate.go b/oapi_validate.go index b44fbec..2fb595a 100644 --- a/oapi_validate.go +++ b/oapi_validate.go @@ -88,6 +88,16 @@ type Options struct { // Prefix is stripped from the request path before validation. This is useful when the API is mounted under a sub-path // (e.g. "/api") that isn't part of the OpenAPI spec's paths. The prefix must start with "/" if set. Prefix string + // 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 the middleware to validate that incoming requests match the given OpenAPI 3.x spec, allowing explicit configuration. @@ -181,6 +191,7 @@ func ValidateRequestFromContext(ctx *echo.Context, router routers.Router, option Request: req, PathParams: pathParams, Route: route, + Options: filterOptions(options), } // Pass the Echo context into the request validator, so that any callbacks @@ -188,7 +199,6 @@ func ValidateRequestFromContext(ctx *echo.Context, router routers.Router, option requestContext := context.WithValue(context.Background(), EchoContextKey, ctx) //nolint:staticcheck if options != nil { - validationInput.Options = &options.Options validationInput.ParamDecoder = options.ParamDecoder requestContext = context.WithValue(requestContext, UserDataKey, options.UserData) //nolint:staticcheck } @@ -256,6 +266,19 @@ func GetUserData(c context.Context) any { return c.Value(UserDataKey) } +// filterOptions returns the openapi3filter.Options to validate a request with: +// a copy of options.Options, so the caller's struct is never modified, with +// KeepValidatedValue enabled unless options.DiscardValidatedValue is set. nil +// options get the defaults, which keep the validated value. +func filterOptions(options *Options) *openapi3filter.Options { + if options == nil { + return &openapi3filter.Options{KeepValidatedValue: true} + } + filterOpts := options.Options + filterOpts.KeepValidatedValue = !options.DiscardValidatedValue + return &filterOpts +} + // attempt to get the skipper from the options whether it is set or not func getSkipperFromOptions(options *Options) echomiddleware.Skipper { if options == nil { diff --git a/oapi_validate_test.go b/oapi_validate_test.go index 83adb10..1021cf1 100644 --- a/oapi_validate_test.go +++ b/oapi_validate_test.go @@ -549,7 +549,9 @@ func TestOapiRequestValidatorWithPrefixRequestBody(t *testing.T) { require.NoError(t, got.readErr) assert.Equal(t, body, string(got.body)) assert.Equal(t, "/api/resource", got.path) - assert.False(t, got.hasValidated, "body should only expose ValidatedValue() with KeepValidatedValue") + // KeepValidatedValue is on by default. + require.True(t, got.hasValidated, "handler's body should expose ValidatedValue()") + assert.Equal(t, map[string]any{"name": "x"}, got.validated) // A body that doesn't match the schema is still rejected. got = prefixedHandlerResult{} @@ -645,6 +647,79 @@ components: }) } +// TestOapiRequestValidatorKeepValidatedValue checks that the middleware enables +// openapi3filter.Options.KeepValidatedValue unless Options.DiscardValidatedValue +// is set, so the handler's request body exposes the validated value through +// ValidatedValue() while remaining readable. +func TestOapiRequestValidatorKeepValidatedValue(t *testing.T) { + // Not JSON-canonical on purpose: the handler must see these exact bytes, + // not a re-encoding of the decoded value. + const body = `{ "name" : "x" }` + + spec, err := openapi3.NewLoader().LoadFromData(testSchema) + require.NoError(t, err) + + // serve sends body to POST /resource through mw and returns what the + // handler saw. + serve := func(t *testing.T, mw echo.MiddlewareFunc) prefixedHandlerResult { + t.Helper() + e := echo.New() + e.Use(mw) + var got prefixedHandlerResult + e.POST("/resource", got.handler(http.StatusNoContent)) + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, newRawJSONPost("http://test.hostname/resource", body)) + + require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) + require.True(t, got.called, "handler should have been called") + require.NoError(t, got.readErr) + assert.Equal(t, body, string(got.body), "body should still be readable with the original bytes") + return got + } + + t.Run("default without options", func(t *testing.T) { + got := serve(t, OapiRequestValidator(spec)) + require.True(t, got.hasValidated, "handler's body should expose ValidatedValue()") + assert.Equal(t, map[string]any{"name": "x"}, got.validated) + }) + + t.Run("default with empty options", func(t *testing.T) { + options := &Options{} + got := serve(t, OapiRequestValidatorWithOptions(spec, options)) + require.True(t, got.hasValidated, "handler's body should expose ValidatedValue()") + assert.Equal(t, map[string]any{"name": "x"}, got.validated) + assert.False(t, options.Options.KeepValidatedValue, "the caller's options should not be modified") + }) + + t.Run("DiscardValidatedValue", func(t *testing.T) { + got := serve(t, OapiRequestValidatorWithOptions(spec, &Options{ + DiscardValidatedValue: true, + })) + assert.False(t, got.hasValidated, "handler's body should not expose ValidatedValue()") + }) + + t.Run("DiscardValidatedValue overrides openapi3filter KeepValidatedValue", func(t *testing.T) { + got := serve(t, OapiRequestValidatorWithOptions(spec, &Options{ + DiscardValidatedValue: true, + Options: openapi3filter.Options{ + KeepValidatedValue: true, + }, + })) + assert.False(t, got.hasValidated, "handler's body should not expose ValidatedValue()") + }) + + t.Run("ExcludeRequestBody", func(t *testing.T) { + got := serve(t, OapiRequestValidatorWithOptions(spec, &Options{ + Options: openapi3filter.Options{ + ExcludeRequestBody: true, + }, + })) + // The body was not validated, so there is no validated value to keep. + assert.False(t, got.hasValidated, "handler's body should not expose ValidatedValue()") + }) +} + // TestOapiRequestValidatorWithPrefixEncodedPathParams checks that the prefix // is trimmed from URL.RawPath as well as URL.Path, and both are restored // before the handler runs.