From d16a4ecf059440d2486809c9f06909547e8928ed Mon Sep 17 00:00:00 2001 From: toim Date: Mon, 15 Jun 2026 20:58:03 +0300 Subject: [PATCH] backport PR 3016 from v4 --- echo.go | 11 ++ echo_fs.go | 23 ++-- echo_fs_encoded_separator_test.go | 138 +++++++++++++------- echo_fs_test.go | 41 ++++-- group_fs.go | 2 +- internal/pathutil/pathutil.go | 30 ----- internal/pathutil/pathutil_test.go | 27 ---- middleware/static.go | 25 ++-- middleware/static_encoded_separator_test.go | 73 ++++++++--- middleware/static_test.go | 77 +++++++++++ 10 files changed, 289 insertions(+), 158 deletions(-) delete mode 100644 internal/pathutil/pathutil.go delete mode 100644 internal/pathutil/pathutil_test.go diff --git a/echo.go b/echo.go index 0fa994112..2233c50aa 100644 --- a/echo.go +++ b/echo.go @@ -106,6 +106,17 @@ type Echo struct { Debug bool HideBanner bool HidePort bool + + // EnablePathUnescapingStaticFiles enables path parameter (param: *) unescaping for Static/StaticFS methods. + // Default false (safe): encoded slashes (%2f) in the wildcard param are NOT decoded, + // preventing ACL bypass where /admin%2fprivate.txt bypasses a /admin/* route guard by + // not matching that route but having its wildcard param decoded to admin/private.txt. + // Set to true only when serving files whose names contain URL-encoded characters + // (e.g. "hello world.txt" via /hello%20world.txt) and you are not relying on + // route-based ACL guards to restrict access. + // If you are enabling this option, make sure you understand the security implications. + // See: https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq + EnablePathUnescapingStaticFiles bool } // Route contains a handler and information for matching against requests. diff --git a/echo_fs.go b/echo_fs.go index f111095f1..ed27b992e 100644 --- a/echo_fs.go +++ b/echo_fs.go @@ -12,8 +12,6 @@ import ( "path" "path/filepath" "strings" - - "github.com/labstack/echo/v4/internal/pathutil" ) type filesystem struct { @@ -38,7 +36,7 @@ func (e *Echo) Static(pathPrefix, fsRoot string) *Route { return e.Add( http.MethodGet, pathPrefix+"*", - StaticDirectoryHandler(subFs, false), + StaticDirectoryHandler(subFs, !e.EnablePathUnescapingStaticFiles), ) } @@ -51,24 +49,23 @@ func (e *Echo) StaticFS(pathPrefix string, filesystem fs.FS) *Route { return e.Add( http.MethodGet, pathPrefix+"*", - StaticDirectoryHandler(filesystem, false), + StaticDirectoryHandler(filesystem, !e.EnablePathUnescapingStaticFiles), ) } -// StaticDirectoryHandler creates handler function to serve files from provided file system +// StaticDirectoryHandler creates handler function to serve files from provided file system. // When disablePathUnescaping is set then file name from path is not unescaped and is served as is. +// +// Note: when disablePathUnescaping=false, the handler decodes the wildcard param before serving. +// If route guards (e.g. e.GET("/admin/*", forbidden)) are used to restrict parts of the +// filesystem, an encoded separator (%2F) or encoded dot-dot (%2E%2E) in the URL can resolve to +// a path that the router never matched against the guard route. Do not rely on route guards +// alone to restrict a filesystem served by this handler. +// See https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) HandlerFunc { return func(c Context) error { p := c.Param("*") if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice - // The router matches routes against the raw, still-encoded request path, so an - // encoded path separator (%2F or %5C) is not treated as a segment boundary during - // routing. Unescaping it here would let it act as a separator and resolve a file - // outside the path the router authorized, bypassing route-level middleware (e.g. auth - // on a sibling route). No real filename contains a separator, so reject it as not found. - if pathutil.HasEncodedPathSeparator(p) { - return ErrNotFound - } tmpPath, err := url.PathUnescape(p) if err != nil { return fmt.Errorf("failed to unescape path variable: %w", err) diff --git a/echo_fs_encoded_separator_test.go b/echo_fs_encoded_separator_test.go index 16752e41d..d169e0380 100644 --- a/echo_fs_encoded_separator_test.go +++ b/echo_fs_encoded_separator_test.go @@ -15,65 +15,113 @@ import ( // Regression for GHSA-vfp3-v2gw-7wfq (v4 backport): an encoded path separator (%2F or %5C) // must not let a static file request resolve across a separator and bypass route-level middleware. func TestStaticDirectoryHandler_EncodedSeparatorDoesNotBypassRoute(t *testing.T) { - fsys := fstest.MapFS{ - "admin/secret.txt": {Data: []byte("TOP-SECRET")}, - "index.html": {Data: []byte("public")}, - } - e := New() - g := e.Group("/admin", func(next HandlerFunc) HandlerFunc { - return func(c Context) error { return c.String(http.StatusForbidden, "denied") } - }) - g.GET("/*", func(c Context) error { return c.String(http.StatusOK, "reached-protected-handler") }) - e.StaticFS("/", fsys) - - cases := []struct { + var testCases = []struct { + name string target string wantCode int wantBody string }{ - {"/admin/secret.txt", http.StatusForbidden, "denied"}, // protected route fires - {"/admin%2Fsecret.txt", http.StatusNotFound, ""}, // encoded slash rejected, no disclosure - {"/admin%2fsecret.txt", http.StatusNotFound, ""}, // lower-case hex variant - {"/admin%5Csecret.txt", http.StatusNotFound, ""}, // encoded backslash (Windows separator) neutralized by path.Clean - {"/admin%252Fsecret.txt", http.StatusNotFound, ""}, // double-encoded: single unescape -> literal filename, not a separator - {"/index.html", http.StatusOK, "public"}, // legitimate static file still served + { + name: "protected route fires", + target: "/admin/secret.txt", + wantCode: http.StatusForbidden, + wantBody: "denied", + }, + { + name: "encoded slash rejected, no disclosure", + target: "/admin%2Fsecret.txt", + wantCode: http.StatusNotFound, + wantBody: "", + }, + { + name: "lower-case hex variant", + target: "/admin%2fsecret.txt", + wantCode: http.StatusNotFound, + wantBody: "", + }, + { + name: "encoded backslash variant - Windows specific related", + target: "/admin%5Csecret.txt", + wantCode: http.StatusNotFound, + wantBody: "", + }, + { + name: "double-encoded: single unescape -> literal filename, not a separator", + target: "/admin%252Fsecret.txt", + wantCode: http.StatusNotFound, + wantBody: "", + }, + { + name: "legitimate static file still served", + target: "/index.html", + wantCode: http.StatusOK, + wantBody: "public", + }, } - for _, tc := range cases { - req := httptest.NewRequest(http.MethodGet, tc.target, nil) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - assert.Equal(t, tc.wantCode, rec.Code, "GET %s", tc.target) - if tc.wantBody != "" { - assert.Equal(t, tc.wantBody, rec.Body.String(), "GET %s", tc.target) - } - assert.NotContains(t, rec.Body.String(), "TOP-SECRET", "GET %s leaked protected file", tc.target) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fsys := fstest.MapFS{ + "admin/secret.txt": {Data: []byte("TOP-SECRET")}, + "index.html": {Data: []byte("public")}, + } + e := New() + g := e.Group("/admin", func(next HandlerFunc) HandlerFunc { + return func(c Context) error { return c.String(http.StatusForbidden, "denied") } + }) + g.GET("/*", func(c Context) error { return c.String(http.StatusOK, "reached-protected-handler") }) + e.StaticFS("/", fsys) + + req := httptest.NewRequest(http.MethodGet, tc.target, nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + assert.Equal(t, tc.wantCode, rec.Code, "GET %s", tc.target) + if tc.wantBody != "" { + assert.Equal(t, tc.wantBody, rec.Body.String(), "GET %s", tc.target) + } + assert.NotContains(t, rec.Body.String(), "TOP-SECRET", "GET %s leaked protected file", tc.target) + }) } } // A Group-mounted StaticFS shares StaticDirectoryHandler, so it must reject the // same encoded separators when served under a non-root prefix. func TestGroupStaticFS_EncodedSeparatorDoesNotBypassRoute(t *testing.T) { - fsys := fstest.MapFS{ - "admin/secret.txt": {Data: []byte("TOP-SECRET")}, - "index.html": {Data: []byte("public")}, - } - e := New() - g := e.Group("/files") - g.StaticFS("/", fsys) - - cases := []struct { + var testCases = []struct { + name string target string wantCode int }{ - {"/files/index.html", http.StatusOK}, - {"/files/admin%2Fsecret.txt", http.StatusNotFound}, - {"/files/admin%5Csecret.txt", http.StatusNotFound}, + { + name: "ok", + target: "/files/index.html", + wantCode: http.StatusOK, + }, + { + name: "nok, encoded slash", + target: "/files/admin%2Fsecret.txt", + wantCode: http.StatusNotFound, + }, + { + name: "nok encoded backslash", + target: "/files/admin%5Csecret.txt", + wantCode: http.StatusNotFound, + }, } - for _, tc := range cases { - req := httptest.NewRequest(http.MethodGet, tc.target, nil) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - assert.Equal(t, tc.wantCode, rec.Code, "GET %s", tc.target) - assert.NotContains(t, rec.Body.String(), "TOP-SECRET", "GET %s leaked protected file", tc.target) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fsys := fstest.MapFS{ + "admin/secret.txt": {Data: []byte("TOP-SECRET")}, + "index.html": {Data: []byte("public")}, + } + e := New() + g := e.Group("/files") + g.StaticFS("/", fsys) + + req := httptest.NewRequest(http.MethodGet, tc.target, nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + assert.Equal(t, tc.wantCode, rec.Code, "GET %s", tc.target) + assert.NotContains(t, rec.Body.String(), "TOP-SECRET", "GET %s leaked protected file", tc.target) + }) } } diff --git a/echo_fs_test.go b/echo_fs_test.go index 75f32dfb0..8416afa59 100644 --- a/echo_fs_test.go +++ b/echo_fs_test.go @@ -15,14 +15,15 @@ import ( func TestEcho_StaticFS(t *testing.T) { var testCases = []struct { - name string - givenPrefix string - givenFs fs.FS - givenFsRoot string - whenURL string - expectStatus int - expectHeaderLocation string - expectBodyStartsWith string + name string + givenPrefix string + givenFs fs.FS + givenFsRoot string + givenEnablePathUnescapingStaticFiles bool + whenURL string + expectStatus int + expectHeaderLocation string + expectBodyStartsWith string }{ { name: "ok", @@ -140,10 +141,7 @@ func TestEcho_StaticFS(t *testing.T) { expectBodyStartsWith: "{\"message\":\"Not Found\"}\n", }, { - // An encoded slash (%2f) is rejected outright (GHSA-vfp3-v2gw-7wfq): the router matches - // on the raw path so %2f is not a separator, and unescaping it here would let it act as - // one. No redirect is emitted, closing the open-redirect vector. - name: "encoded slash is rejected, not redirected", + name: "do not unescape path variables by default", givenPrefix: "/", givenFs: os.DirFS("_fixture/"), whenURL: "/open.redirect.hackercom%2f..", @@ -151,11 +149,30 @@ func TestEcho_StaticFS(t *testing.T) { expectHeaderLocation: "", expectBodyStartsWith: "{\"message\":\"Not Found\"}\n", }, + { + name: "do not accept encoded dots in path (%2E%2E is `..`) to traverse within filesystem boundary", + givenPrefix: "/", + givenFs: os.DirFS("_fixture/"), + givenEnablePathUnescapingStaticFiles: false, + whenURL: `/folder/%2E%2E/index.html`, // `/folder/../index.html` + expectStatus: http.StatusNotFound, + expectBodyStartsWith: "{\"message\":\"Not Found\"}\n", + }, + { + name: "allow encoded dots in path (%2E%2E is `..`) to traverse within filesystem when path unescaping is enabled", + givenPrefix: "/", + givenFs: os.DirFS("_fixture/"), + givenEnablePathUnescapingStaticFiles: true, + whenURL: `/folder/%2E%2E/index.html`, // `/folder/../index.html` + expectStatus: http.StatusOK, + expectBodyStartsWith: "", + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { e := New() + e.EnablePathUnescapingStaticFiles = tc.givenEnablePathUnescapingStaticFiles tmpFs := tc.givenFs if tc.givenFsRoot != "" { diff --git a/group_fs.go b/group_fs.go index c1b7ec2d3..5246ea06b 100644 --- a/group_fs.go +++ b/group_fs.go @@ -23,7 +23,7 @@ func (g *Group) StaticFS(pathPrefix string, filesystem fs.FS) { g.Add( http.MethodGet, pathPrefix+"*", - StaticDirectoryHandler(filesystem, false), + StaticDirectoryHandler(filesystem, !g.echo.EnablePathUnescapingStaticFiles), ) } diff --git a/internal/pathutil/pathutil.go b/internal/pathutil/pathutil.go deleted file mode 100644 index 9934fa31c..000000000 --- a/internal/pathutil/pathutil.go +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: MIT -// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors - -// Package pathutil holds internal helpers for safely handling request and file -// paths. It is internal so it can be shared between the echo package and the -// middleware package without becoming part of the public API. -package pathutil - -// HasEncodedPathSeparator reports whether s contains a percent-encoded path -// separator, case-insensitively: %2F/%2f (forward slash) or %5C/%5c (backslash). -// Backslash is included as defense-in-depth against Windows-style separators even -// though fs.FS itself only uses forward slashes. -// -// Such sequences let an attacker smuggle a separator past the router, which -// matches on the raw encoded path, so they must be rejected before unescaping -// when resolving static files. -func HasEncodedPathSeparator(s string) bool { - for i := 0; i+2 < len(s); i++ { - if s[i] != '%' { - continue - } - switch { - case s[i+1] == '2' && (s[i+2] == 'f' || s[i+2] == 'F'): // %2F - return true - case s[i+1] == '5' && (s[i+2] == 'c' || s[i+2] == 'C'): // %5C - return true - } - } - return false -} diff --git a/internal/pathutil/pathutil_test.go b/internal/pathutil/pathutil_test.go deleted file mode 100644 index fd9a9d90d..000000000 --- a/internal/pathutil/pathutil_test.go +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: MIT -// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors - -package pathutil - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestHasEncodedPathSeparator(t *testing.T) { - for s, want := range map[string]bool{ - "foo/bar.txt": false, - "100%25.txt": false, // encoded percent, not a separator - "a%2Fb": true, - "a%2fb": true, - "a%5Cb": true, - "a%5cb": true, - "trailing%2F": true, - "%2F": true, - "%2": false, // truncated, not a full sequence - "": false, - } { - assert.Equal(t, want, HasEncodedPathSeparator(s), "input=%q", s) - } -} diff --git a/middleware/static.go b/middleware/static.go index 41066ebc4..16a983896 100644 --- a/middleware/static.go +++ b/middleware/static.go @@ -14,7 +14,6 @@ import ( "strings" "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/internal/pathutil" "github.com/labstack/gommon/bytes" ) @@ -49,6 +48,15 @@ type StaticConfig struct { // Filesystem provides access to the static content. // Optional. Defaults to http.Dir(config.Root) Filesystem http.FileSystem `yaml:"-"` + + // EnablePathUnescaping enables path parameter (param: *) unescaping. + // Default false (safe): encoded slashes (%2f) in the wildcard param are NOT decoded, + // preventing ACL bypass where /admin%2fprivate.txt bypasses a /admin/* route guard by + // not matching that route but having its wildcard param decoded to admin/private.txt. + // Set to true only when serving files whose names contain URL-encoded characters + // (e.g. "hello world.txt" via /hello%20world.txt) and you are not relying on + // route-based ACL guards to restrict access. + EnablePathUnescaping bool `yaml:"enablePathUnescaping"` } const html = ` @@ -171,16 +179,11 @@ func StaticWithConfig(config StaticConfig) echo.MiddlewareFunc { if strings.HasSuffix(c.Path(), "*") { // When serving from a group, e.g. `/static*`. p = c.Param("*") } - // The router matched on the raw, still-encoded path, so an encoded path separator in - // the wildcard would only now become a real separator and resolve a file the matched - // route never authorized, bypassing route-level middleware. Reject it before unescaping - // (see echo.StaticDirectoryHandler). - if pathutil.HasEncodedPathSeparator(p) { - return echo.ErrNotFound - } - p, err = url.PathUnescape(p) - if err != nil { - return + if config.EnablePathUnescaping { + p, err = url.PathUnescape(p) + if err != nil { + return + } } // Security: We use path.Clean() (not filepath.Clean()) because: // 1. HTTP URLs always use forward slashes, regardless of server OS diff --git a/middleware/static_encoded_separator_test.go b/middleware/static_encoded_separator_test.go index dd5c32954..2e20ed88c 100644 --- a/middleware/static_encoded_separator_test.go +++ b/middleware/static_encoded_separator_test.go @@ -18,28 +18,63 @@ import ( // group must not let an encoded separator in the wildcard bypass route-level middleware // and disclose a file the matched route never authorized. func TestStatic_EncodedSeparatorDoesNotBypassRoute(t *testing.T) { - root := t.TempDir() - assert.NoError(t, os.MkdirAll(filepath.Join(root, "admin"), 0o755)) - assert.NoError(t, os.WriteFile(filepath.Join(root, "admin", "secret.txt"), []byte("TOP-SECRET"), 0o644)) - assert.NoError(t, os.WriteFile(filepath.Join(root, "index.html"), []byte("public"), 0o644)) - - e := echo.New() - g := e.Group("/files", StaticWithConfig(StaticConfig{Root: root})) - g.GET("/*", func(c echo.Context) error { return echo.ErrNotFound }) - - cases := []struct { + var testCases = []struct { + name string + config StaticConfig target string wantCode int }{ - {"/files/index.html", http.StatusOK}, - {"/files/admin%2Fsecret.txt", http.StatusNotFound}, - {"/files/admin%2fsecret.txt", http.StatusNotFound}, + { + name: "ok, legitimate file is served", + target: "/files/index.html", + wantCode: http.StatusOK, + }, + { + // With EnablePathUnescaping=false (default/safe), the wildcard param "admin%2Fsecret.txt" + // is NOT decoded, so the FS lookup is for literal "admin%2Fsecret.txt" which does + // not exist → falls through → 404. ACL is not bypassed. + name: "ok, encoded slash returns 404 with default safe config (EnablePathUnescaping=false)", + target: "/files/admin%2Fsecret.txt", + wantCode: http.StatusNotFound, + }, + { + name: "ok, lower-case encoded slash also returns 404", + target: "/files/admin%2fsecret.txt", + wantCode: http.StatusNotFound, + }, + { + // With EnablePathUnescaping=true, the wildcard param "admin%2Fsecret.txt" IS decoded + // to "admin/secret.txt" before the FS lookup. The router already routed to /* so the + // ACL guard on /admin/* never ran. The file is served — ACL bypass. + // Only use EnablePathUnescaping: true when not relying on route-based ACL guards. + name: "nok, encoded slash bypasses ACL when EnablePathUnescaping=true", + config: StaticConfig{EnablePathUnescaping: true}, + target: "/files/admin%2Fsecret.txt", + wantCode: http.StatusOK, + }, } - for _, tc := range cases { - req := httptest.NewRequest(http.MethodGet, tc.target, nil) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - assert.Equal(t, tc.wantCode, rec.Code, "GET %s", tc.target) - assert.NotContains(t, rec.Body.String(), "TOP-SECRET", "GET %s leaked protected file", tc.target) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + assert.NoError(t, os.MkdirAll(filepath.Join(root, "admin"), 0o755)) + assert.NoError(t, os.WriteFile(filepath.Join(root, "admin", "secret.txt"), []byte("TOP-SECRET"), 0o644)) + assert.NoError(t, os.WriteFile(filepath.Join(root, "index.html"), []byte("public"), 0o644)) + + cfg := tc.config + cfg.Root = root + + e := echo.New() + g := e.Group("/files", StaticWithConfig(cfg)) + g.GET("/*", func(c echo.Context) error { return echo.ErrNotFound }) + + req := httptest.NewRequest(http.MethodGet, tc.target, nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + assert.Equal(t, tc.wantCode, rec.Code, "GET %s", tc.target) + if tc.wantCode != http.StatusOK { + assert.NotContains(t, rec.Body.String(), "TOP-SECRET", "GET %s leaked protected file", tc.target) + } + }) } } diff --git a/middleware/static_test.go b/middleware/static_test.go index 183580efb..aebe787c9 100644 --- a/middleware/static_test.go +++ b/middleware/static_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "testing" "testing/fstest" @@ -211,6 +212,82 @@ func TestStatic(t *testing.T) { } } +func TestStaticMiddlewareAndRouterInconsistentEscaping(t *testing.T) { + var testCases = []struct { + name string + givenConfig StaticConfig + whenURL string + expectCode int + expectBodyContains string + expectBodyNotContains string + }{ + { + name: "ok, normal file is served", + givenConfig: StaticConfig{}, + whenURL: "/test.txt", + expectCode: http.StatusOK, + expectBodyContains: "test", + }, + { + name: "ok, direct request to restricted path is blocked by ACL route", + givenConfig: StaticConfig{}, + whenURL: "/admin/secret.txt", + expectCode: http.StatusForbidden, + }, + { + // With EnablePathUnescaping=false (default/safe), the wildcard param "admin%2fsecret.txt" + // is NOT decoded, so the FS lookup is for literal "admin%2fsecret.txt" which does + // not exist → falls through to the /* handler → 404. ACL is not bypassed. + name: "ok, encoded slash returns 404 with default safe config (EnablePathUnescaping=false)", + givenConfig: StaticConfig{}, + whenURL: "/admin%2fsecret.txt", + expectCode: http.StatusNotFound, + expectBodyNotContains: "TOP-SECRET", + }, + { + // With EnablePathUnescaping=true, the wildcard param "admin%2fsecret.txt" IS decoded + // to "admin/secret.txt". The router already routed to /* (encoded %2f prevented matching + // /admin/*), so the ACL guard never ran. The file is served — ACL bypass. + // Only use EnablePathUnescaping: true when not relying on route-based ACL guards. + name: "nok, encoded slash bypasses ACL when EnablePathUnescaping=true", + givenConfig: StaticConfig{EnablePathUnescaping: true}, + whenURL: "/admin%2fsecret.txt", + expectCode: http.StatusOK, + expectBodyContains: "TOP-SECRET", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + assert.NoError(t, os.MkdirAll(filepath.Join(root, "admin"), 0o755)) + assert.NoError(t, os.WriteFile(filepath.Join(root, "admin", "secret.txt"), []byte("TOP-SECRET"), 0o644)) + assert.NoError(t, os.WriteFile(filepath.Join(root, "test.txt"), []byte("test content"), 0o644)) + + cfg := tc.givenConfig + cfg.Root = root + + e := echo.New() + e.Use(StaticWithConfig(cfg)) + e.GET("/*", func(c echo.Context) error { return echo.ErrNotFound }) + e.GET("/admin/*", func(c echo.Context) error { return echo.ErrForbidden }) + + req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + assert.Equal(t, tc.expectCode, rec.Code) + body := rec.Body.String() + if tc.expectBodyContains != "" { + assert.Contains(t, body, tc.expectBodyContains) + } + if tc.expectBodyNotContains != "" { + assert.NotContains(t, body, tc.expectBodyNotContains) + } + }) + } +} + func TestStatic_GroupWithStatic(t *testing.T) { var testCases = []struct { name string