@@ -134,14 +134,15 @@ func TestNewDefaultFS(t *testing.T) {
134134
135135func TestEcho_StaticFS (t * testing.T ) {
136136 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
137+ givenFs fs.FS
138+ name string
139+ givenPrefix string
140+ givenFsRoot string
141+ givenEnablePathUnescapingStaticFiles bool
142+ whenURL string
143+ expectHeaderLocation string
144+ expectBodyStartsWith string
145+ expectStatus int
145146 }{
146147 {
147148 name : "ok" ,
@@ -259,22 +260,40 @@ func TestEcho_StaticFS(t *testing.T) {
259260 expectBodyStartsWith : "{\" message\" :\" Not Found\" }\n " ,
260261 },
261262 {
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" ,
263+ name : "do not unescape path variables by default" ,
266264 givenPrefix : "/" ,
267265 givenFs : os .DirFS ("_fixture/" ),
268266 whenURL : "/open.redirect.hackercom%2f.." ,
269267 expectStatus : http .StatusNotFound ,
270- expectHeaderLocation : "" ,
271268 expectBodyStartsWith : "{\" message\" :\" Not Found\" }\n " ,
272269 },
270+ {
271+ name : "possible open redirect vulnerability when unescaping path variables in static handler" ,
272+ givenPrefix : "/" ,
273+ givenFs : os .DirFS ("_fixture/" ),
274+ givenEnablePathUnescapingStaticFiles : true ,
275+ whenURL : "/open.redirect.hackercom%2f.." ,
276+ expectStatus : http .StatusMovedPermanently ,
277+ expectHeaderLocation : "/open.redirect.hackercom%2f../" , // location starting with `//open` would be very bad
278+ expectBodyStartsWith : "" ,
279+ },
280+ {
281+ name : "possible open redirect vulnerability when not unescaping path variables in static handler" ,
282+ givenPrefix : "/" ,
283+ givenFs : os .DirFS ("_fixture/dist/" ),
284+ givenEnablePathUnescapingStaticFiles : false ,
285+ whenURL : "/%2f.." ,
286+ expectStatus : http .StatusOK ,
287+ expectHeaderLocation : "" ,
288+ expectBodyStartsWith : "This filename is escaped to `/..` in URL.Path\n " ,
289+ },
273290 }
274291
275292 for _ , tc := range testCases {
276293 t .Run (tc .name , func (t * testing.T ) {
277- e := New ()
294+ e := NewWithConfig (Config {
295+ EnablePathUnescapingStaticFiles : tc .givenEnablePathUnescapingStaticFiles ,
296+ })
278297
279298 tmpFs := tc .givenFs
280299 if tc .givenFsRoot != "" {
@@ -305,6 +324,87 @@ func TestEcho_StaticFS(t *testing.T) {
305324 }
306325}
307326
327+ func TestStaticDirectoryHandlerAndRouterInconsistentEscaping (t * testing.T ) {
328+ var testCases = []struct {
329+ name string
330+ givenEnablePathUnescapingStaticFiles bool
331+ givenRouterUnescapePathParamValues bool
332+ whenURL string
333+ expectBodyStartsWith string
334+ expectStatus int
335+ }{
336+ {
337+ name : "ok, file is served from not-forbidden path" ,
338+ givenEnablePathUnescapingStaticFiles : false ,
339+ whenURL : "/test.txt" ,
340+ expectBodyStartsWith : "test.txt contents\n " ,
341+ expectStatus : http .StatusOK ,
342+ },
343+ {
344+ name : "ok, forbidden path is matched by route wildcard and forbidden by that" ,
345+ givenEnablePathUnescapingStaticFiles : false ,
346+ whenURL : "/admin/private.txt" ,
347+ expectBodyStartsWith : "{\" message\" :\" Forbidden\" }\n " ,
348+ expectStatus : http .StatusForbidden ,
349+ },
350+ {
351+ name : "ok, escaped filename from forbidden path is not escaped and results 404" ,
352+ givenEnablePathUnescapingStaticFiles : false , // router path escaping and StaticDirectoryHandler is consistent
353+ whenURL : "/admin%2fprivate.txt" ,
354+ expectBodyStartsWith : "{\" message\" :\" Not Found\" }\n " ,
355+ expectStatus : http .StatusNotFound ,
356+ },
357+ {
358+ name : "nok, escaped filename from forbidden path is escaped and returns file contents (handler escapes)" ,
359+ givenEnablePathUnescapingStaticFiles : true , // router path escaping and StaticDirectoryHandler is NOT consistent
360+ whenURL : "/admin%2fprivate.txt" ,
361+ expectBodyStartsWith : "private file\n " ,
362+ expectStatus : http .StatusOK ,
363+ },
364+ {
365+ name : "nok, escaped filename from forbidden path is escaped and returns file contents (router escapes)" ,
366+ givenRouterUnescapePathParamValues : true , // router path escaping and StaticDirectoryHandler is NOT consistent
367+ whenURL : "/admin%2fprivate.txt" ,
368+ expectBodyStartsWith : "private file\n " ,
369+ expectStatus : http .StatusOK ,
370+ },
371+ }
372+ for _ , tc := range testCases {
373+ t .Run (tc .name , func (t * testing.T ) {
374+ r := NewRouter (RouterConfig {
375+ UnescapePathParamValues : tc .givenRouterUnescapePathParamValues ,
376+ })
377+ e := NewWithConfig (Config {
378+ EnablePathUnescapingStaticFiles : tc .givenEnablePathUnescapingStaticFiles ,
379+ Router : r ,
380+ Filesystem : os .DirFS ("./_fixture/dist" ),
381+ })
382+
383+ // 1. share folder contents from the server root. This folder actually contains folder `admin` which contents we
384+ // want to forbid from downloading
385+ e .Static ("/" , "public" )
386+
387+ // 2. naively assume that everything under /admin folder is now forbidden
388+ e .GET ("/admin/*" , func (c * Context ) error {
389+ return ErrForbidden
390+ })
391+
392+ req := httptest .NewRequest (http .MethodGet , tc .whenURL , nil )
393+ rec := httptest .NewRecorder ()
394+
395+ e .ServeHTTP (rec , req )
396+
397+ assert .Equal (t , tc .expectStatus , rec .Code )
398+ body := rec .Body .String ()
399+ if tc .expectBodyStartsWith != "" {
400+ assert .True (t , strings .HasPrefix (body , tc .expectBodyStartsWith ))
401+ } else {
402+ assert .Equal (t , "" , body )
403+ }
404+ })
405+ }
406+ }
407+
308408func TestEcho_FileFS (t * testing.T ) {
309409 var testCases = []struct {
310410 whenFS fs.FS
0 commit comments