Skip to content

Commit 687695b

Browse files
committed
revert StaticDirectoryHandler to redirect using unescaped path as basis for redirect
1 parent acb3391 commit 687695b

4 files changed

Lines changed: 89 additions & 32 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
private file
1+
public/admin/private.txt - private file

echo.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ Learn more at https://echo.labstack.com
4343
package echo
4444

4545
import (
46-
"cmp"
4746
stdContext "context"
4847
"encoding/json"
4948
"errors"
@@ -310,7 +309,19 @@ type Config struct {
310309
// route-based ACL guards to restrict access.
311310
// If you are enabling this option, make sure you understand the security implications.
312311
// See: https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq
313-
// Enabling RouterConfig.UseEscapedPathForMatching makes path escaping in static files and router consistent.
312+
//
313+
// Enabling RouterConfig.UseEscapedPathForMatching makes this field irrelevant and can lead to security issues when
314+
// using different Routes to exclude some of the files from being served.
315+
// e.g. if you serve files from directory as such and use different route to exclude some of the files from being served.
316+
// 0. given folder structure:
317+
// public/
318+
// public/index.html
319+
// public/admin/private.txt
320+
// 1. share `public/` folder contents from the server root with `e.Static("/", "public")`
321+
// 2. naively assume that everything under /admin folder is now forbidden
322+
// e.GET("/admin/*", func(c *Context) error { return echo.ErrForbidden })
323+
// Then request to `/assets/../admin%2fprivate.txt` will be served as router does not match it to guarded route.
324+
//
314325
// Applies to methods: Echo.Static, Echo.StaticFS, Group.Static, Group.StaticFS.
315326
EnablePathUnescapingStaticFiles bool
316327
}
@@ -635,10 +646,7 @@ func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) Handle
635646
// If the request is for a directory and does not end with "/" redirect to path which ends with "/"
636647
p = c.Request().URL.Path
637648
if fi.IsDir() && len(p) > 0 && p[len(p)-1] != '/' {
638-
// prefer RawPath in redirect to avoid inconsistency when router is possible already escaping or not escaping
639-
// as we are redirecting to same path, except added "/" at the end the RawPath is more consistent behavior
640-
redirectPath := cmp.Or(c.Request().URL.RawPath, c.Request().URL.Path)
641-
return c.Redirect(http.StatusMovedPermanently, sanitizeURI(redirectPath+"/"))
649+
return c.Redirect(http.StatusMovedPermanently, sanitizeURI(p+"/"))
642650
}
643651
return fsFile(c, name, fileSystem)
644652
}

echo_test.go

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -278,10 +278,12 @@ func TestEcho_StaticFS(t *testing.T) {
278278
givenPrefix: "/",
279279
givenFs: os.DirFS("_fixture/"),
280280
givenEnablePathUnescapingStaticFiles: true,
281-
whenURL: "/open.redirect.hackercom%2f..",
282-
expectStatus: http.StatusMovedPermanently,
283-
expectHeaderLocation: "/open.redirect.hackercom%2f../", // location starting with `//open` would be very bad
284-
expectBodyStartsWith: "",
281+
// `//open.redirect.hackercom/..` resolves to directory but does not end with `/` so redirect is done but this
282+
// redirect can not be to path starting with `//` or `\\` (open redirect)
283+
whenURL: "/%2fopen.redirect.hackercom%2f..",
284+
expectStatus: http.StatusMovedPermanently,
285+
expectHeaderLocation: "/open.redirect.hackercom/../", // location starting with `//open` would be very bad
286+
expectBodyStartsWith: "",
285287
},
286288
{
287289
name: "possible open redirect vulnerability when not unescaping path variables in static handler",
@@ -335,59 +337,98 @@ func TestStaticDirectoryHandlerAndRouterInconsistentEscaping(t *testing.T) {
335337
name string
336338
givenEnablePathUnescapingStaticFiles bool
337339
givenRouterUnescapePathParamValues bool
340+
givenRouterUseEscapedPathForMatching bool
338341
whenURL string
339-
expectBodyStartsWith string
342+
expectBody string
340343
expectStatus int
341344
}{
342345
{
343346
name: "ok, file is served from not-forbidden path",
344347
givenEnablePathUnescapingStaticFiles: false,
345348
whenURL: "/test.txt",
346-
expectBodyStartsWith: "test.txt contents\n",
349+
expectBody: "test.txt contents\n",
347350
expectStatus: http.StatusOK,
348351
},
349352
{
350353
name: "ok, forbidden path is matched by route wildcard and forbidden by that",
351354
givenEnablePathUnescapingStaticFiles: false,
352355
whenURL: "/admin/private.txt",
353-
expectBodyStartsWith: "{\"message\":\"Forbidden\"}\n",
356+
expectBody: "{\"message\":\"Forbidden\"}\n",
354357
expectStatus: http.StatusForbidden,
355358
},
356359
{
357-
name: "ok, escaped filename from forbidden path is not escaped and results 404",
360+
name: "ok, escaped filename from forbidden path is routed to guarded route",
361+
givenEnablePathUnescapingStaticFiles: false,
362+
givenRouterUnescapePathParamValues: false,
363+
givenRouterUseEscapedPathForMatching: true, // Router uses escaped path (req.URL.RawPath) for matching
364+
whenURL: "/admin%2fprivate.txt",
365+
expectBody: "{\"message\":\"Forbidden\"}\n",
366+
expectStatus: http.StatusForbidden,
367+
},
368+
{
369+
name: "ok, escaped filename from forbidden path is not unescaped and results 404",
358370
givenEnablePathUnescapingStaticFiles: false, // router path escaping and StaticDirectoryHandler is consistent
359371
whenURL: "/admin%2fprivate.txt",
360-
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
372+
expectBody: "{\"message\":\"Not Found\"}\n",
361373
expectStatus: http.StatusNotFound,
362374
},
363375
{
364-
name: "nok, escaped filename from forbidden path is escaped and returns file contents (handler escapes)",
376+
name: "nok, escaped filename from forbidden path is unescaped and returns file contents (handler unescapes)",
365377
givenEnablePathUnescapingStaticFiles: true, // router path escaping and StaticDirectoryHandler is NOT consistent
378+
givenRouterUnescapePathParamValues: false,
379+
whenURL: "/admin%2fprivate.txt",
380+
expectBody: "public/admin/private.txt - private file\n",
381+
expectStatus: http.StatusOK,
382+
},
383+
{
384+
name: "nok, escaped filename from forbidden path is unescaped and returns file contents (router unescapes)",
385+
givenEnablePathUnescapingStaticFiles: false,
386+
givenRouterUnescapePathParamValues: true, // router path escaping and StaticDirectoryHandler is NOT consistent
366387
whenURL: "/admin%2fprivate.txt",
367-
expectBodyStartsWith: "private file\n",
388+
expectBody: "public/admin/private.txt - private file\n",
368389
expectStatus: http.StatusOK,
369390
},
370391
{
371-
name: "nok, escaped filename from forbidden path is escaped and returns file contents (router escapes)",
372-
givenRouterUnescapePathParamValues: true, // router path escaping and StaticDirectoryHandler is NOT consistent
373-
whenURL: "/admin%2fprivate.txt",
374-
expectBodyStartsWith: "private file\n",
375-
expectStatus: http.StatusOK,
392+
name: "nok, unescaped filename from forbidden path is escaped and returns file contents (router unescapes and method unescapes)",
393+
givenEnablePathUnescapingStaticFiles: true,
394+
givenRouterUnescapePathParamValues: true, // consistent path unescaping - makes no difference
395+
whenURL: "/admin%2fprivate.txt",
396+
expectBody: "public/admin/private.txt - private file\n",
397+
expectStatus: http.StatusOK,
398+
},
399+
{
400+
name: "nok, escaped filename, resolves to from forbidden path is not routed to guarded route and includes guarded file",
401+
givenEnablePathUnescapingStaticFiles: false,
402+
givenRouterUnescapePathParamValues: false,
403+
// Router uses escaped path (req.URL.RawPath) for matching, but that file resolves to `admin/private.txt` after path.Clean()
404+
givenRouterUseEscapedPathForMatching: true,
405+
whenURL: "/assets/../admin%2fprivate.txt",
406+
expectBody: "public/admin/private.txt - private file\n",
407+
expectStatus: http.StatusOK,
376408
},
377409
}
378410
for _, tc := range testCases {
379411
t.Run(tc.name, func(t *testing.T) {
380412
r := NewRouter(RouterConfig{
381-
UnescapePathParamValues: tc.givenRouterUnescapePathParamValues,
413+
UnescapePathParamValues: tc.givenRouterUnescapePathParamValues,
414+
UseEscapedPathForMatching: tc.givenRouterUseEscapedPathForMatching,
382415
})
383416
e := NewWithConfig(Config{
384417
EnablePathUnescapingStaticFiles: tc.givenEnablePathUnescapingStaticFiles,
385418
Router: r,
386419
Filesystem: os.DirFS("./_fixture/dist"),
387420
})
388421

389-
// 1. share folder contents from the server root. This folder actually contains folder `admin` which contents we
390-
// want to forbid from downloading
422+
// 0.
423+
// given folder structure:
424+
// private.txt
425+
// public/
426+
// public/index.html
427+
// public/text.txt
428+
// public/admin/private.txt
429+
430+
// 1. share `public/` folder contents from the server root. This folder actually contains subfolder `admin` which
431+
// contents we want to forbid from downloading
391432
e.Static("/", "public")
392433

393434
// 2. naively assume that everything under /admin folder is now forbidden
@@ -402,11 +443,7 @@ func TestStaticDirectoryHandlerAndRouterInconsistentEscaping(t *testing.T) {
402443

403444
assert.Equal(t, tc.expectStatus, rec.Code)
404445
body := rec.Body.String()
405-
if tc.expectBodyStartsWith != "" {
406-
assert.True(t, strings.HasPrefix(body, tc.expectBodyStartsWith))
407-
} else {
408-
assert.Equal(t, "", body)
409-
}
446+
assert.Equal(t, tc.expectBody, body)
410447
})
411448
}
412449
}

middleware/static.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ type StaticConfig struct {
5353
// Optional. Default value false.
5454
IgnoreBase bool
5555

56-
// Deprecated: Use EnablePathUnescaping instead. DisablePathUnescaping will be removed in a future version.
56+
// Deprecated: this field is ignored, use EnablePathUnescaping instead. DisablePathUnescaping will be removed in a future version.
5757
// Note: previously the zero value (false) enabled unescaping, which was the unsafe default.
5858
DisablePathUnescaping bool
5959

@@ -64,6 +64,18 @@ type StaticConfig struct {
6464
// Set to true only when serving files whose names contain URL-encoded characters
6565
// (e.g. "hello world.txt" via /hello%20world.txt) and you are not relying on
6666
// route-based ACL guards to restrict access.
67+
//
68+
// Enabling echo.RouterConfig.UseEscapedPathForMatching makes this field irrelevant and can lead to security issues when
69+
// using different Routes to exclude some of the files from being served.
70+
// e.g. if you serve files from directory as such and use different route to exclude some of the files from being served.
71+
// 0. given folder structure:
72+
// public/
73+
// public/index.html
74+
// public/admin/private.txt
75+
// 1. share `public/` folder contents from the server root with `e.Static("/", "public")`
76+
// 2. naively assume that everything under /admin folder is now forbidden
77+
// e.GET("/admin/*", func(c *Context) error { return echo.ErrForbidden })
78+
// Then request to `/assets/../admin%2fprivate.txt` will be served as router does not match it to guarded route.
6779
EnablePathUnescaping bool
6880

6981
// DirectoryListTemplate is template to list directory contents

0 commit comments

Comments
 (0)