github.com/labstack/echo/v4
Advanced tools
@@ -7,5 +7,7 @@ name: Run checks | ||
| - master | ||
| - v4 | ||
| pull_request: | ||
| branches: | ||
| - master | ||
| - v4 | ||
| workflow_dispatch: | ||
@@ -25,6 +27,6 @@ | ||
| - name: Checkout Code | ||
| uses: actions/checkout@v5 | ||
| uses: actions/checkout@v6 | ||
| - name: Set up Go ${{ matrix.go }} | ||
| uses: actions/setup-go@v5 | ||
| uses: actions/setup-go@v6 | ||
| with: | ||
@@ -31,0 +33,0 @@ go-version: ${{ env.LATEST_GO_VERSION }} |
@@ -7,5 +7,7 @@ name: Run Tests | ||
| - master | ||
| - v4 | ||
| pull_request: | ||
| branches: | ||
| - master | ||
| - v4 | ||
| workflow_dispatch: | ||
@@ -27,4 +29,4 @@ | ||
| # Echo tests with last four major releases (unless there are pressing vulnerabilities) | ||
| # As we depend on `golang.org/x/` libraries which only support last 2 Go releases we could have situations when | ||
| # we derive from last four major releases promise. | ||
| # As we depend on `golang.org/x/` libraries which only support the last 2 Go releases, we could have situations when | ||
| # we derive from the last four major releases promise. | ||
| go: ["1.25", "1.26"] | ||
@@ -35,6 +37,6 @@ name: ${{ matrix.os }} @ Go ${{ matrix.go }} | ||
| - name: Checkout Code | ||
| uses: actions/checkout@v5 | ||
| uses: actions/checkout@v6 | ||
| - name: Set up Go ${{ matrix.go }} | ||
| uses: actions/setup-go@v5 | ||
| uses: actions/setup-go@v6 | ||
| with: | ||
@@ -48,3 +50,3 @@ go-version: ${{ matrix.go }} | ||
| if: success() && matrix.go == env.LATEST_GO_VERSION && matrix.os == 'ubuntu-latest' | ||
| uses: codecov/codecov-action@v5 | ||
| uses: codecov/codecov-action@v6 | ||
| with: | ||
@@ -60,3 +62,3 @@ token: | ||
| - name: Checkout Code (Previous) | ||
| uses: actions/checkout@v5 | ||
| uses: actions/checkout@v6 | ||
| with: | ||
@@ -67,3 +69,3 @@ ref: ${{ github.base_ref }} | ||
| - name: Checkout Code (New) | ||
| uses: actions/checkout@v5 | ||
| uses: actions/checkout@v6 | ||
| with: | ||
@@ -73,3 +75,3 @@ path: new | ||
| - name: Set up Go ${{ matrix.go }} | ||
| uses: actions/setup-go@v5 | ||
| uses: actions/setup-go@v6 | ||
| with: | ||
@@ -76,0 +78,0 @@ go-version: ${{ env.LATEST_GO_VERSION }} |
+49
-0
| # Changelog | ||
| ## v4.15.4 - 2026-06-15 | ||
| **Security** | ||
| Fixes [GHSA-vfp3-v2gw-7wfq](https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq) | ||
| Make serving static file releated methods and middleware not unescape path by default - so how the way Router interprets paths and Static methods/middleware is consistent. | ||
| Given following situation: | ||
| ```go | ||
| // 0. | ||
| // given folder structure: | ||
| // private.txt | ||
| // public/ | ||
| // public/index.html | ||
| // public/text.txt | ||
| // public/admin/private.txt | ||
| // 1. share `public/` folder contents from the server root. This folder actually contains subfolder `admin` which | ||
| // contents we want to forbid from downloading | ||
| e.Static("/", "public") | ||
| // 2. naively assume that everything under /admin folder is now forbidden | ||
| e.GET("/admin/*", func(c *Context) error { | ||
| return ErrForbidden | ||
| }) | ||
| ``` | ||
| Then requests to `/admin%2fprivate.txt` would not be matched to `GET /admin/*` route (routing does not look unescaped path) and static file serving will use unescaped path to serve the file. | ||
| Note: this way of "guarding" subfolders will never work for for paths like `/assets/../admin%2fprivate.txt` which will `path.Clean("/assets/../admin%2fprivate.txt")` to `/admin/private.txt` and are servable if static file serving is configured to unescape paths. | ||
| If you want to guard routes - use middlewares on `Static*` methods and before `Static` middleware. | ||
| **Breaking change / migration:** If you serve files whose names contain URL-encoded characters (e.g., `/hello%20world.txt` → `hello world.txt`), you must now opt in: | ||
| ```go | ||
| e := echo.New() | ||
| e.EnablePathUnescapingStaticFiles = true // <-- enable old behavior | ||
| e.Static("/", "public") | ||
| ``` | ||
| for static middleware | ||
| ```go | ||
| e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ | ||
| EnablePathUnescaping: true, // <-- enable old behavior | ||
| })) | ||
| ``` | ||
| ## v4.15.3 - 2026-06-14 | ||
@@ -4,0 +53,0 @@ |
@@ -18,14 +18,4 @@ // SPDX-License-Identifier: MIT | ||
| 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 | ||
@@ -35,18 +25,61 @@ wantCode int | ||
| }{ | ||
| {"/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) | ||
| }) | ||
| } | ||
@@ -58,25 +91,40 @@ } | ||
| 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) | ||
| }) | ||
| } | ||
| } |
+29
-12
@@ -18,10 +18,11 @@ // SPDX-License-Identifier: MIT | ||
| 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 | ||
| }{ | ||
@@ -144,6 +145,3 @@ { | ||
| { | ||
| // 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: "/", | ||
@@ -156,2 +154,20 @@ givenFs: os.DirFS("_fixture/"), | ||
| }, | ||
| { | ||
| 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: "<!doctype html>", | ||
| }, | ||
| } | ||
@@ -162,2 +178,3 @@ | ||
| e := New() | ||
| e.EnablePathUnescapingStaticFiles = tc.givenEnablePathUnescapingStaticFiles | ||
@@ -164,0 +181,0 @@ tmpFs := tc.givenFs |
+10
-13
@@ -15,4 +15,2 @@ // SPDX-License-Identifier: MIT | ||
| "strings" | ||
| "github.com/labstack/echo/v4/internal/pathutil" | ||
| ) | ||
@@ -42,3 +40,3 @@ | ||
| pathPrefix+"*", | ||
| StaticDirectoryHandler(subFs, false), | ||
| StaticDirectoryHandler(subFs, !e.EnablePathUnescapingStaticFiles), | ||
| ) | ||
@@ -56,8 +54,15 @@ } | ||
| 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 { | ||
@@ -67,10 +72,2 @@ return func(c Context) error { | ||
| 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) | ||
@@ -77,0 +74,0 @@ if err != nil { |
+14
-1
@@ -62,2 +62,3 @@ // SPDX-License-Identifier: MIT | ||
| "golang.org/x/net/http2" | ||
| //lint:ignore SA1019 h2c is required until v4 is supported (end of 2026) | ||
| "golang.org/x/net/http2/h2c" | ||
@@ -109,2 +110,13 @@ ) | ||
| 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 | ||
| } | ||
@@ -272,3 +284,3 @@ | ||
| // Version of Echo | ||
| Version = "4.15.3" | ||
| Version = "4.15.4" | ||
| website = "https://echo.labstack.com" | ||
@@ -851,2 +863,3 @@ // http://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Echo | ||
| s.ErrorLog = e.StdLogger | ||
| //lint:ignore SA1019 h2c is required until v4 is supported (end of 2026) | ||
| s.Handler = h2c.NewHandler(e, h2s) | ||
@@ -853,0 +866,0 @@ if e.Debug { |
+5
-5
@@ -9,4 +9,4 @@ module github.com/labstack/echo/v4 | ||
| github.com/valyala/fasttemplate v1.2.2 | ||
| golang.org/x/crypto v0.50.0 | ||
| golang.org/x/net v0.53.0 | ||
| golang.org/x/crypto v0.53.0 | ||
| golang.org/x/net v0.56.0 | ||
| golang.org/x/time v0.15.0 | ||
@@ -17,9 +17,9 @@ ) | ||
| github.com/davecgh/go-spew v1.1.1 // indirect | ||
| github.com/mattn/go-colorable v0.1.14 // indirect | ||
| github.com/mattn/go-colorable v0.1.15 // indirect | ||
| github.com/mattn/go-isatty v0.0.22 // indirect | ||
| github.com/pmezard/go-difflib v1.0.0 // indirect | ||
| github.com/valyala/bytebufferpool v1.0.0 // indirect | ||
| golang.org/x/sys v0.43.0 // indirect | ||
| golang.org/x/text v0.36.0 // indirect | ||
| golang.org/x/sys v0.46.0 // indirect | ||
| golang.org/x/text v0.38.0 // indirect | ||
| gopkg.in/yaml.v3 v3.0.1 // indirect | ||
| ) |
+10
-10
@@ -5,4 +5,4 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
| github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= | ||
| github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= | ||
| github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | ||
| github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= | ||
| github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | ||
| github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= | ||
@@ -18,10 +18,10 @@ github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= | ||
| github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= | ||
| golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= | ||
| golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= | ||
| golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= | ||
| golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= | ||
| golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= | ||
| golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | ||
| golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= | ||
| golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= | ||
| golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= | ||
| golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= | ||
| golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= | ||
| golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= | ||
| golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= | ||
| golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | ||
| golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= | ||
| golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= | ||
| golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= | ||
@@ -28,0 +28,0 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= |
+1
-1
@@ -26,3 +26,3 @@ // SPDX-License-Identifier: MIT | ||
| pathPrefix+"*", | ||
| StaticDirectoryHandler(filesystem, false), | ||
| StaticDirectoryHandler(filesystem, !g.echo.EnablePathUnescapingStaticFiles), | ||
| ) | ||
@@ -29,0 +29,0 @@ } |
@@ -21,26 +21,61 @@ // SPDX-License-Identifier: MIT | ||
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
@@ -11,2 +11,3 @@ // SPDX-License-Identifier: MIT | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
@@ -215,2 +216,78 @@ "testing" | ||
| 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) { | ||
@@ -217,0 +294,0 @@ var testCases = []struct { |
+14
-11
@@ -17,3 +17,2 @@ // SPDX-License-Identifier: MIT | ||
| "github.com/labstack/echo/v4" | ||
| "github.com/labstack/echo/v4/internal/pathutil" | ||
| "github.com/labstack/gommon/bytes" | ||
@@ -53,2 +52,11 @@ ) | ||
| 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"` | ||
| } | ||
@@ -176,13 +184,8 @@ | ||
| } | ||
| // 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 | ||
| if config.EnablePathUnescaping { | ||
| p, err = url.PathUnescape(p) | ||
| if err != nil { | ||
| return | ||
| } | ||
| } | ||
| p, err = url.PathUnescape(p) | ||
| if err != nil { | ||
| return | ||
| } | ||
| // Security: We use path.Clean() (not filepath.Clean()) because: | ||
@@ -189,0 +192,0 @@ // 1. HTTP URLs always use forward slashes, regardless of server OS |
| // 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) | ||
| } | ||
| } |
| // 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 | ||
| } |