httpd: redirect a trailing slash to the path without it !431

merged merged by cmc on 2026-09-19 19:22 UTC · krz/gitbay:uri-normalize into main

3 files changed, +77 −2

Layout: unified · split

CHANGELOG.org +3
@@ -28,6 +28,9 @@ The web findings from the forge comparison (#232).
28- =web theme set system|light|dark= and an Appearance section on the 28- =web theme set system|light|dark= and an Appearance section on the
29 account page fix the colour scheme per account. Migration 0057 adds 29 account page fix the colour scheme per account. Migration 0057 adds
30 =users.theme=. 30 =users.theme=.
31- A path that only misses because of a trailing slash redirects to the
32 path without it: =/cmc/= reaches =/cmc=, =/cmc/-/snippets/= reaches
33 =/cmc/-/snippets= (#233).
31 34
32* v1.29.0 — 2026-09-19 35* v1.29.0 — 2026-09-19
33 36
internal/httpd/routes.go +27 −2
@@ -231,8 +231,9 @@ func (s *Server) Handler() http.Handler {
231 mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler) 231 mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler)
232 } 232 }
233 // A path no pattern matches gets the 404 page, not net/http's 233 // A path no pattern matches gets the 404 page, not net/http's
234 // plain-text body (#232). 234 // plain-text body (#232), unless dropping a trailing slash makes it
235 mux.HandleFunc("/", s.notFound) 235 // match (#233).
236 mux.HandleFunc("/", s.unmatched(mux))
236 var h http.Handler = mux 237 var h http.Handler = mux
237 if len(s.cfg.GoImport) > 0 { 238 if len(s.cfg.GoImport) > 0 {
238 h = s.goImportHandler(mux) 239 h = s.goImportHandler(mux)
@@ -242,6 +243,30 @@ func (s *Server) Handler() http.Handler {
242 return compressed(s.pagesRouter(s.securityHeaders(h))) 243 return compressed(s.pagesRouter(s.securityHeaders(h)))
243} 244}
244 245
246// unmatched answers a path no pattern matched. A GET whose path ends in
247// a slash and resolves without it redirects there, so /cmc/ reaches /cmc
248// and /cmc/-/snippets/ reaches /cmc/-/snippets (#233). Everything else is
249// the 404 page. Patterns ending in a {path...} wildcard already accept
250// the slash and never arrive here.
251func (s *Server) unmatched(mux *http.ServeMux) http.HandlerFunc {
252 return func(w http.ResponseWriter, r *http.Request) {
253 p := r.URL.Path
254 if len(p) > 1 && strings.HasSuffix(p, "/") && (r.Method == "GET" || r.Method == "HEAD") {
255 trimmed := r.Clone(r.Context())
256 u := *r.URL
257 u.Path = strings.TrimRight(p, "/")
258 trimmed.URL = &u
259 // The fallback itself is registered at "/", so a miss
260 // reports that pattern; a real route reports its own.
261 if _, pattern := mux.Handler(trimmed); pattern != "" && pattern != "/" {
262 http.Redirect(w, r, u.String(), http.StatusMovedPermanently)
263 return
264 }
265 }
266 s.notFound(w, r)
267 }
268}
269
245// securityHeaders sets defensive response headers on every reply. The CSP 270// securityHeaders sets defensive response headers on every reply. The CSP
246// is strict where it can be: no scripts at all (the UI needs none), no 271// is strict where it can be: no scripts at all (the UI needs none), no
247// plugins, no embedding. Inline styles are allowed because label chips 272// plugins, no embedding. Inline styles are allowed because label chips
internal/httpd/trailingslash_test.go added +47
@@ -0,0 +1,47 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "testing"
8)
9
10// A path that only misses because of a trailing slash redirects to the
11// path without it, query intact; one that misses either way is 404, and
12// a POST is never redirected (#233).
13func TestTrailingSlashRedirects(t *testing.T) {
14 h := plainServer().Handler()
15 for from, to := range map[string]string{
16 "/cmc/": "/cmc",
17 "/cmc/ccleberg/": "/cmc/ccleberg",
18 "/cmc/-/snippets/": "/cmc/-/snippets",
19 "/krz/gitbay/mrs/12/": "/krz/gitbay/mrs/12",
20 "/krz/gitbay/issues/?q=x": "/krz/gitbay/issues?q=x",
21 } {
22 w := get(t, h, from, nil)
23 if w.Code != http.StatusMovedPermanently || w.Header().Get("Location") != to {
24 t.Errorf("%s: %d %q, want 301 %q", from, w.Code, w.Header().Get("Location"), to)
25 }
26 }
27 for _, p := range []string{"/", "/krz/gitbay/nothing/"} {
28 if w := get(t, h, p, nil); p != "/" && w.Code != 404 {
29 t.Errorf("%s: status %d, want 404", p, w.Code)
30 }
31 }
32 // A Location must not leave the site: the mux cleans a leading //
33 // before the fallback runs, and a backslash is escaped (#153).
34 for _, p := range []string{"//evil.example/", "/\\evil.example/"} {
35 w := get(t, h, p, nil)
36 if loc := w.Header().Get("Location"); strings.HasPrefix(loc, "//") || strings.Contains(loc, "\\") {
37 t.Errorf("%s: Location %q leaves the site", p, loc)
38 }
39 }
40 r := httptest.NewRequest("POST", "/cmc/", nil)
41 r.Host = "forge.test"
42 w := httptest.NewRecorder()
43 h.ServeHTTP(w, r)
44 if w.Code != 404 {
45 t.Errorf("POST /cmc/: status %d, want 404", w.Code)
46 }
47}