Skip to content

Commit a9ede66

Browse files
vishrclaude
andauthored
fix(middleware/static): don't double-unescape request path (#2599) (#3006)
* fix(middleware/static): don't double-unescape request path (#2599) http.Request.URL.Path is already decoded by net/http, but the static middleware unescaped it again by default, so files whose names contain a percent sign were not downloadable ("/100%25.txt" -> "invalid URL escape"). Default pathUnescape to false; only the wildcard param from a group route (set explicitly below) may still be escaped and is handled by the existing DisablePathUnescaping toggle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(static): add percent-encoded traversal-protection case (#2599) Companion test confirming that defaulting pathUnescape to false does not weaken traversal protection — single-, double-, and dot-encoded "../" all stay within the served root. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ec9515a commit a9ede66

2 files changed

Lines changed: 68 additions & 1 deletion

File tree

middleware/static.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,10 @@ func (config StaticConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
196196
}
197197

198198
p := c.Request().URL.Path
199-
pathUnescape := true
199+
// URL.Path is already decoded by net/http, so it must not be unescaped
200+
// again (doing so breaks file names containing '%', see #2599). Only the
201+
// wildcard param from a group route (set below) may still be escaped.
202+
pathUnescape := false
200203
if strings.HasSuffix(c.Path(), "*") { // When serving from a group, e.g. `/static*`.
201204
p = c.Param("*")
202205
pathUnescape = !config.DisablePathUnescaping // because router could already do PathUnescape

middleware/static_percent_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
package middleware
5+
6+
import (
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
"testing/fstest"
11+
12+
"github.com/labstack/echo/v5"
13+
"github.com/stretchr/testify/assert"
14+
)
15+
16+
// Regression test for #2599: a file whose name contains a percent sign must be
17+
// downloadable. http.Request.URL.Path is already decoded by net/http, so the
18+
// static middleware must not unescape it a second time (which turned
19+
// "/100%25.txt" into an "invalid URL escape" error or a missing file).
20+
func TestStatic_servesFileWithPercentInName(t *testing.T) {
21+
e := echo.New()
22+
e.Use(StaticWithConfig(StaticConfig{
23+
Root: ".",
24+
Filesystem: fstest.MapFS{
25+
"100%.txt": &fstest.MapFile{Data: []byte("hundred percent")},
26+
"foo%20bar.txt": &fstest.MapFile{Data: []byte("literal percent twenty")},
27+
},
28+
}))
29+
30+
cases := map[string]string{
31+
"/100%25.txt": "hundred percent",
32+
"/foo%2520bar.txt": "literal percent twenty",
33+
}
34+
for url, want := range cases {
35+
req := httptest.NewRequest(http.MethodGet, url, nil)
36+
rec := httptest.NewRecorder()
37+
e.ServeHTTP(rec, req)
38+
assert.Equal(t, http.StatusOK, rec.Code, "GET %s should serve the file", url)
39+
assert.Equal(t, want, rec.Body.String(), "GET %s should return the file contents", url)
40+
}
41+
}
42+
43+
// Companion to #2599: not unescaping the already-decoded path must not weaken
44+
// traversal protection. A percent-encoded "../" must not escape the served root
45+
// (and notably must not be re-assembled from double-encoded input, as the old
46+
// double-unescape could do).
47+
func TestStatic_percentEncodedTraversalIsBlocked(t *testing.T) {
48+
e := echo.New()
49+
e.Use(StaticWithConfig(StaticConfig{
50+
Root: "public",
51+
Filesystem: fstest.MapFS{
52+
"public/page.txt": &fstest.MapFile{Data: []byte("public page")},
53+
"secret.txt": &fstest.MapFile{Data: []byte("SECRET")},
54+
},
55+
}))
56+
57+
for _, url := range []string{"/..%2fsecret.txt", "/..%252fsecret.txt", "/%2e%2e%2fsecret.txt"} {
58+
req := httptest.NewRequest(http.MethodGet, url, nil)
59+
rec := httptest.NewRecorder()
60+
e.ServeHTTP(rec, req)
61+
assert.NotEqual(t, http.StatusOK, rec.Code, "GET %s must not escape the served root", url)
62+
assert.NotContains(t, rec.Body.String(), "SECRET", "GET %s must not leak files above the root", url)
63+
}
64+
}

0 commit comments

Comments
 (0)