@@ -20,6 +20,7 @@ import (
2020 "runtime"
2121 "strings"
2222 "testing"
23+ "testing/fstest"
2324 "time"
2425
2526 "github.com/stretchr/testify/assert"
@@ -133,15 +134,21 @@ func TestNewDefaultFS(t *testing.T) {
133134}
134135
135136func TestEcho_StaticFS (t * testing.T ) {
137+ dotsInFilenameFS := fstest.MapFS {
138+ // On Windows filename `%2f..` can not be created but in Linux it is possible.
139+ "%2f.." : {Data : []byte ("This filename is escaped to `/..` in URL.Path" )},
140+ }
141+
136142 var testCases = []struct {
137- givenFs fs.FS
138- name string
139- givenPrefix string
140- givenFsRoot string
141- whenURL string
142- expectHeaderLocation string
143- expectBodyStartsWith string
144- expectStatus int
143+ givenFs fs.FS
144+ name string
145+ givenPrefix string
146+ givenFsRoot string
147+ givenEnablePathUnescapingStaticFiles bool
148+ whenURL string
149+ expectHeaderLocation string
150+ expectBodyStartsWith string
151+ expectStatus int
145152 }{
146153 {
147154 name : "ok" ,
@@ -250,6 +257,33 @@ func TestEcho_StaticFS(t *testing.T) {
250257 expectStatus : http .StatusNotFound ,
251258 expectBodyStartsWith : "{\" message\" :\" Not Found\" }\n " ,
252259 },
260+ {
261+ name : "do not accept encoded dots in path (%2E%2E is `..`) to traverse within filesystem boundary" ,
262+ givenPrefix : "/" ,
263+ givenFs : os .DirFS ("_fixture/" ),
264+ givenEnablePathUnescapingStaticFiles : false ,
265+ whenURL : `/dist/public/%2E%2E/private.txt` , // `/dist/public/../private.txt`
266+ expectStatus : http .StatusNotFound ,
267+ expectBodyStartsWith : "{\" message\" :\" Not Found\" }\n " ,
268+ },
269+ {
270+ name : "allow encoded dots in path (%2E%2E is `..`) to traverse within filesystem" ,
271+ givenPrefix : "/" ,
272+ givenFs : os .DirFS ("_fixture/" ),
273+ givenEnablePathUnescapingStaticFiles : true ,
274+ whenURL : `/dist/public/%2E%2E/private.txt` , // `/dist/public/../private.txt`
275+ expectStatus : http .StatusOK ,
276+ expectBodyStartsWith : "private file" ,
277+ },
278+ {
279+ name : "ok, file with space in name is served when path unescaping is enabled" ,
280+ givenPrefix : "/" ,
281+ givenFs : os .DirFS ("_fixture/dist/public" ),
282+ givenEnablePathUnescapingStaticFiles : true ,
283+ whenURL : "/hello%20world.txt" ,
284+ expectStatus : http .StatusOK ,
285+ expectBodyStartsWith : "hello world file" ,
286+ },
253287 {
254288 name : "do not allow directory traversal (slash - unix separator)" ,
255289 givenPrefix : "/" ,
@@ -259,22 +293,42 @@ func TestEcho_StaticFS(t *testing.T) {
259293 expectBodyStartsWith : "{\" message\" :\" Not Found\" }\n " ,
260294 },
261295 {
262- // An encoded slash (%2f) is rejected outright (GHSA-vfp3-v2gw-7wfq): by default the
263- // router matches on the raw path so %2f is not a separator, and unescaping it here
264- // would let it act as one. No redirect is emitted, closing the open-redirect vector.
265- name : "encoded slash is rejected, not redirected" ,
296+ name : "do not unescape path variables by default" ,
266297 givenPrefix : "/" ,
267298 givenFs : os .DirFS ("_fixture/" ),
268299 whenURL : "/open.redirect.hackercom%2f.." ,
269300 expectStatus : http .StatusNotFound ,
270- expectHeaderLocation : "" ,
271301 expectBodyStartsWith : "{\" message\" :\" Not Found\" }\n " ,
272302 },
303+ {
304+ name : "possible open redirect vulnerability when unescaping path variables in static handler" ,
305+ givenPrefix : "/" ,
306+ givenFs : os .DirFS ("_fixture/" ),
307+ givenEnablePathUnescapingStaticFiles : true ,
308+ // `//open.redirect.hackercom/..` resolves to directory but does not end with `/` so redirect is done but this
309+ // redirect can not be to path starting with `//` or `\\` (open redirect)
310+ whenURL : "/%2fopen.redirect.hackercom%2f.." ,
311+ expectStatus : http .StatusMovedPermanently ,
312+ expectHeaderLocation : "/open.redirect.hackercom/../" , // location starting with `//open` would be very bad
313+ expectBodyStartsWith : "" ,
314+ },
315+ {
316+ name : "possible open redirect vulnerability when not unescaping path variables in static handler" ,
317+ givenPrefix : "/" ,
318+ givenFs : dotsInFilenameFS ,
319+ givenEnablePathUnescapingStaticFiles : false ,
320+ whenURL : "/%2f.." ,
321+ expectStatus : http .StatusOK ,
322+ expectHeaderLocation : "" ,
323+ expectBodyStartsWith : "This filename is escaped to `/..` in URL.Path" ,
324+ },
273325 }
274326
275327 for _ , tc := range testCases {
276328 t .Run (tc .name , func (t * testing.T ) {
277- e := New ()
329+ e := NewWithConfig (Config {
330+ EnablePathUnescapingStaticFiles : tc .givenEnablePathUnescapingStaticFiles ,
331+ })
278332
279333 tmpFs := tc .givenFs
280334 if tc .givenFsRoot != "" {
@@ -305,6 +359,122 @@ func TestEcho_StaticFS(t *testing.T) {
305359 }
306360}
307361
362+ func TestStaticDirectoryHandlerAndRouterInconsistentEscaping (t * testing.T ) {
363+ var testCases = []struct {
364+ name string
365+ givenEnablePathUnescapingStaticFiles bool
366+ givenRouterUnescapePathParamValues bool
367+ givenRouterUseEscapedPathForMatching bool
368+ whenURL string
369+ expectBody string
370+ expectStatus int
371+ }{
372+ {
373+ name : "ok, file is served from not-forbidden path" ,
374+ givenEnablePathUnescapingStaticFiles : false ,
375+ whenURL : "/test.txt" ,
376+ expectBody : "test.txt contents" ,
377+ expectStatus : http .StatusOK ,
378+ },
379+ {
380+ name : "ok, forbidden path is matched by route wildcard and forbidden by that" ,
381+ givenEnablePathUnescapingStaticFiles : false ,
382+ whenURL : "/admin/private.txt" ,
383+ expectBody : "{\" message\" :\" Forbidden\" }" ,
384+ expectStatus : http .StatusForbidden ,
385+ },
386+ {
387+ name : "ok, escaped filename from forbidden path is routed to guarded route" ,
388+ givenEnablePathUnescapingStaticFiles : false ,
389+ givenRouterUnescapePathParamValues : false ,
390+ givenRouterUseEscapedPathForMatching : true , // Router uses escaped path (req.URL.RawPath) for matching
391+ whenURL : "/admin%2fprivate.txt" ,
392+ expectBody : "{\" message\" :\" Forbidden\" }" ,
393+ expectStatus : http .StatusForbidden ,
394+ },
395+ {
396+ name : "ok, escaped filename from forbidden path is not unescaped and results 404" ,
397+ givenEnablePathUnescapingStaticFiles : false , // router path escaping and StaticDirectoryHandler is consistent
398+ whenURL : "/admin%2fprivate.txt" ,
399+ expectBody : "{\" message\" :\" Not Found\" }" ,
400+ expectStatus : http .StatusNotFound ,
401+ },
402+ {
403+ name : "nok, escaped filename from forbidden path is unescaped and returns file contents (handler unescapes)" ,
404+ givenEnablePathUnescapingStaticFiles : true , // router path escaping and StaticDirectoryHandler is NOT consistent
405+ givenRouterUnescapePathParamValues : false ,
406+ whenURL : "/admin%2fprivate.txt" ,
407+ expectBody : "public/admin/private.txt - private file" ,
408+ expectStatus : http .StatusOK ,
409+ },
410+ {
411+ name : "nok, escaped filename from forbidden path is unescaped and returns file contents (router unescapes)" ,
412+ givenEnablePathUnescapingStaticFiles : false ,
413+ givenRouterUnescapePathParamValues : true , // router path escaping and StaticDirectoryHandler is NOT consistent
414+ whenURL : "/admin%2fprivate.txt" ,
415+ expectBody : "public/admin/private.txt - private file" ,
416+ expectStatus : http .StatusOK ,
417+ },
418+ {
419+ name : "nok, unescaped filename from forbidden path is escaped and returns file contents (router unescapes and method unescapes)" ,
420+ givenEnablePathUnescapingStaticFiles : true ,
421+ givenRouterUnescapePathParamValues : true , // consistent path unescaping - makes no difference
422+ whenURL : "/admin%2fprivate.txt" ,
423+ expectBody : "public/admin/private.txt - private file" ,
424+ expectStatus : http .StatusOK ,
425+ },
426+ {
427+ name : "nok, escaped filename, resolves to from forbidden path is not routed to guarded route and includes guarded file" ,
428+ givenEnablePathUnescapingStaticFiles : false ,
429+ givenRouterUnescapePathParamValues : false ,
430+ // Router uses escaped path (req.URL.RawPath) for matching, but that file resolves to `admin/private.txt` after path.Clean()
431+ givenRouterUseEscapedPathForMatching : true ,
432+ whenURL : "/assets/../admin%2fprivate.txt" ,
433+ expectBody : "public/admin/private.txt - private file" ,
434+ expectStatus : http .StatusOK ,
435+ },
436+ }
437+ for _ , tc := range testCases {
438+ t .Run (tc .name , func (t * testing.T ) {
439+ r := NewRouter (RouterConfig {
440+ UnescapePathParamValues : tc .givenRouterUnescapePathParamValues ,
441+ UseEscapedPathForMatching : tc .givenRouterUseEscapedPathForMatching ,
442+ })
443+ e := NewWithConfig (Config {
444+ EnablePathUnescapingStaticFiles : tc .givenEnablePathUnescapingStaticFiles ,
445+ Router : r ,
446+ Filesystem : os .DirFS ("./_fixture/dist" ),
447+ })
448+
449+ // 0.
450+ // given folder structure:
451+ // private.txt
452+ // public/
453+ // public/index.html
454+ // public/text.txt
455+ // public/admin/private.txt
456+
457+ // 1. share `public/` folder contents from the server root. This folder actually contains subfolder `admin` which
458+ // contents we want to forbid from downloading
459+ e .Static ("/" , "public" )
460+
461+ // 2. naively assume that everything under /admin folder is now forbidden
462+ e .GET ("/admin/*" , func (c * Context ) error {
463+ return ErrForbidden
464+ })
465+
466+ req := httptest .NewRequest (http .MethodGet , tc .whenURL , nil )
467+ rec := httptest .NewRecorder ()
468+
469+ e .ServeHTTP (rec , req )
470+
471+ assert .Equal (t , tc .expectStatus , rec .Code )
472+ body := strings .TrimRight (rec .Body .String (), "\r \n " )
473+ assert .Equal (t , tc .expectBody , body )
474+ })
475+ }
476+ }
477+
308478func TestEcho_FileFS (t * testing.T ) {
309479 var testCases = []struct {
310480 whenFS fs.FS
0 commit comments