From 2802aaf7e7cc99566389bc18569fc4d1aa8e6e93 Mon Sep 17 00:00:00 2001 From: blacksud0 Date: Thu, 20 Aug 2026 16:09:41 +0300 Subject: [PATCH] fix: trim Options.Prefix in place so the request body survives security validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request.Clone copies the Body reference. With Prefix set and a security scheme in the spec, openapi3filter's security validation reads the body from the live request and restores a fresh reader onto the clone only, so every POST/PUT/PATCH through a prefixed mount reached its handler with a drained, zero-byte body — even with ExcludeRequestBody enabled, since the read belongs to the security path. Trimming URL.Path (and RawPath, which the clone approach left untrimmed) in place with a deferred restore keeps RequestValidationInput.Request the live request, so the body restore lands where it is needed, and the restore runs before next so handlers still observe the original path — the existing prefix test's path assertion keeps passing. The new regression test fails against the previous clone-based handling and passes with this change. Fixes #16 --- oapi_validate.go | 17 ++++++++--- prefix_body_test.go | 73 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 prefix_body_test.go diff --git a/oapi_validate.go b/oapi_validate.go index 614065b..b44fbec 100644 --- a/oapi_validate.go +++ b/oapi_validate.go @@ -132,10 +132,19 @@ func ValidateRequestFromContext(ctx *echo.Context, router routers.Router, option req := ctx.Request() if options != nil && options.Prefix != "" { - // Clone the request so downstream handlers still see the original path. - clone := req.Clone(req.Context()) - clone.URL.Path = strings.TrimPrefix(clone.URL.Path, options.Prefix) - req = clone + // Trim the prefix in place rather than on a clone: Request.Clone copies + // the Body reference, so openapi3filter's security validation would read + // the original request's body and restore a fresh reader onto the clone + // only, leaving the handler a drained body. The deferred restore runs + // before the handler, which therefore still sees the original path. + path, rawPath := req.URL.Path, req.URL.RawPath + req.URL.Path = strings.TrimPrefix(path, options.Prefix) + if rawPath != "" { + req.URL.RawPath = strings.TrimPrefix(rawPath, options.Prefix) + } + defer func() { + req.URL.Path, req.URL.RawPath = path, rawPath + }() } route, pathParams, err := router.FindRoute(req) diff --git a/prefix_body_test.go b/prefix_body_test.go new file mode 100644 index 0000000..54b0edf --- /dev/null +++ b/prefix_body_test.go @@ -0,0 +1,73 @@ +package echomiddleware + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" +) + +func TestPrefixKeepsTheRequestBody(t *testing.T) { + const specYAML = ` +openapi: "3.0.0" +info: + title: t + version: 1.0.0 +paths: + /thing: + post: + operationId: createThing + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '204': + description: ok +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer +` + spec, err := openapi3.NewLoader().LoadFromData([]byte(specYAML)) + require.NoError(t, err) + + e := echo.New() + e.Use(OapiRequestValidatorWithOptions(spec, &Options{ + Prefix: "/api", + SilenceServersWarning: true, + Options: openapi3filter.Options{ + ExcludeRequestBody: true, + AuthenticationFunc: func(context.Context, *openapi3filter.AuthenticationInput) error { + return nil + }, + }, + })) + + var got []byte + e.POST("/api/thing", func(c *echo.Context) error { + got, _ = io.ReadAll(c.Request().Body) + return c.NoContent(http.StatusNoContent) + }) + + r := httptest.NewRequest(http.MethodPost, "/api/thing", strings.NewReader(`{"name":"x"}`)) + r.Header.Set("content-type", "application/json") + r.Header.Set("authorization", "Bearer token") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, r) + + require.Equal(t, http.StatusNoContent, rec.Code) + require.NotEmpty(t, got, "the handler received a drained body") +}