Commit 25772eed72
25772eed7278387de9ed07803ac5f909a24ec153
parent: 1a40b22072
Verified · cmc ci/build: success ci/test: success
cmc <hello@cleberg.net> · 2026-09-19 16:20 UTC
httpd: redirect a trailing slash to the path without it
The "/" fallback checks whether the path resolves with its trailing slash
dropped and answers 301 to that path, query intact, for GET and HEAD.
A path that misses either way, or any other method, still gets the 404
page. Patterns ending in a {path...} wildcard accept the slash already
and never reach the fallback.
Ref #233
CHANGELOG.org
+3
| @@ -28,6 +28,9 @@ The web findings from the forge comparison (#232). |
| 28 | 28 | - =web theme set system|light|dark= and an Appearance section on the |
| 29 | 29 | account page fix the colour scheme per account. Migration 0057 adds |
| 30 | 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 | 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 | 231 | mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler) |
| 232 | 232 | } |
| 233 | 233 | // A path no pattern matches gets the 404 page, not net/http's |
| 234 | | // plain-text body (#232). |
| 235 | | mux.HandleFunc("/", s.notFound) |
| 234 | // plain-text body (#232), unless dropping a trailing slash makes it |
| 235 | // match (#233). |
| 236 | mux.HandleFunc("/", s.unmatched(mux)) |
| 236 | 237 | var h http.Handler = mux |
| 237 | 238 | if len(s.cfg.GoImport) > 0 { |
| 238 | 239 | h = s.goImportHandler(mux) |
| @@ -242,6 +243,30 @@ func (s *Server) Handler() http.Handler { |
| 242 | 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. |
| 251 | func (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 | 270 | // securityHeaders sets defensive response headers on every reply. The CSP |
| 246 | 271 | // is strict where it can be: no scripts at all (the UI needs none), no |
| 247 | 272 | // plugins, no embedding. Inline styles are allowed because label chips |
internal/httpd/trailingslash_test.go
added
+47
| @@ -0,0 +1,47 @@ |
| 1 | package httpd |
| 2 | |
| 3 | import ( |
| 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). |
| 13 | func 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 | } |