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).
2828- =web theme set system|light|dark= and an Appearance section on the
2929 account page fix the colour scheme per account. Migration 0057 adds
3030 =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).
3134
3235* v1.29.0 — 2026-09-19
3336
internal/httpd/routes.go +27 −2
@@ -231,8 +231,9 @@ func (s *Server) Handler() http.Handler {
231231 mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler)
232232 }
233233 // 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))
236237 var h http.Handler = mux
237238 if len(s.cfg.GoImport) > 0 {
238239 h = s.goImportHandler(mux)
@@ -242,6 +243,30 @@ func (s *Server) Handler() http.Handler {
242243 return compressed(s.pagesRouter(s.securityHeaders(h)))
243244}
244245
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
245270// securityHeaders sets defensive response headers on every reply. The CSP
246271// is strict where it can be: no scripts at all (the UI needs none), no
247272// 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}