Skip to content

Commit 6eb3588

Browse files
authored
middleware: harden RedirectSlashes handler (#1044)
1 parent de0d16e commit 6eb3588

2 files changed

Lines changed: 65 additions & 2 deletions

File tree

middleware/strip.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,22 @@ func RedirectSlashes(next http.Handler) http.Handler {
4747
} else {
4848
path = r.URL.Path
4949
}
50+
5051
if len(path) > 1 && path[len(path)-1] == '/' {
51-
// Trim all leading and trailing slashes (e.g., "//evil.com", "/some/path//")
52-
path = "/" + strings.Trim(path, "/")
52+
// Normalize backslashes to forward slashes to prevent "/\evil.com" style redirects
53+
// that some clients may interpret as protocol-relative.
54+
path = strings.ReplaceAll(path, `\`, `/`)
55+
56+
// Collapse leading/trailing slashes and force a single leading slash.
57+
path := "/" + strings.Trim(path, "/")
58+
5359
if r.URL.RawQuery != "" {
5460
path = fmt.Sprintf("%s?%s", path, r.URL.RawQuery)
5561
}
5662
http.Redirect(w, r, path, 301)
5763
return
5864
}
65+
5966
next.ServeHTTP(w, r)
6067
}
6168
return http.HandlerFunc(fn)

middleware/strip_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"net/http"
55
"net/http/httptest"
66
"net/url"
7+
"strings"
78
"testing"
89

910
"github.com/go-chi/chi/v5"
@@ -271,3 +272,58 @@ func TestStripPrefix(t *testing.T) {
271272
t.Fatalf("got: %q, want: %q", resp, "404 page not found\n")
272273
}
273274
}
275+
276+
func TestRedirectSlashes_PreventBackslashRelativeOpenRedirect(t *testing.T) {
277+
h := RedirectSlashes(http.NotFoundHandler())
278+
279+
tests := []struct {
280+
name string
281+
target string
282+
}{
283+
{
284+
name: `raw backslash: /\evil.com/`,
285+
target: `/\evil.com/`,
286+
},
287+
{
288+
name: `encoded backslash: /%5Cevil.com/`,
289+
target: "/%5Cevil.com/",
290+
},
291+
}
292+
293+
for _, tc := range tests {
294+
t.Run(tc.name, func(t *testing.T) {
295+
req := httptest.NewRequest(http.MethodGet, "http://example.test"+tc.target, nil)
296+
rr := httptest.NewRecorder()
297+
298+
h.ServeHTTP(rr, req)
299+
res := rr.Result()
300+
defer res.Body.Close()
301+
302+
if res.StatusCode != http.StatusMovedPermanently {
303+
t.Fatalf("expected %d, got %d", http.StatusMovedPermanently, res.StatusCode)
304+
}
305+
306+
loc := res.Header.Get("Location")
307+
if loc == "" {
308+
t.Fatalf("expected Location header to be set")
309+
}
310+
311+
// The core security assertions:
312+
if strings.Contains(loc, `\`) {
313+
t.Fatalf("Location must not contain backslashes: %q", loc)
314+
}
315+
if strings.HasPrefix(loc, "//") {
316+
t.Fatalf("Location must not be protocol-relative: %q", loc)
317+
}
318+
if !strings.HasPrefix(loc, "/") {
319+
t.Fatalf("Location must be an absolute-path reference starting with '/': %q", loc)
320+
}
321+
322+
// Optional stronger assertion if your middleware normalizes to /evil.com exactly:
323+
// (Keep or remove depending on your chosen behavior.)
324+
if loc != "/evil.com" {
325+
t.Fatalf("expected Location %q, got %q", "/evil.com", loc)
326+
}
327+
})
328+
}
329+
}

0 commit comments

Comments
 (0)