Commit c56ef893fb

c56ef893fbabae870be2538d1cbf3551f48948ab

parent: 078b91ce3e

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 04:50 UTC

web: a trailing-slash redirect cannot leave the site

net/http fills URL.Scheme and URL.Host from an absolute-form request
line, which RFC 7230 requires a server to accept. The #233 handler
copies r.URL, trims the slash and redirects to u.String(), so
`GET http://evil.example/cmc/` answered 301 Location:
http://evil.example/cmc. Clear the three fields after the pattern
lookup; ServeMux matches host patterns on the Host header, not on
URL.Host, so matching is untouched.

Ref #238
internal/httpd/routes.go +5
@@ -263,6 +263,11 @@ func (s *Server) unmatched(mux *http.ServeMux) http.HandlerFunc {
263263 // The fallback itself is registered at "/", so a miss
264264 // reports that pattern; a real route reports its own.
265265 if _, pattern := mux.Handler(trimmed); pattern != "" && pattern != "/" {
266 // net/http fills Scheme and Host from an
267 // absolute-form request line, which RFC 7230
268 // requires a server to accept; carrying them
269 // into Location sends the reader off-site.
270 u.Scheme, u.Host, u.User = "", "", nil
266271 http.Redirect(w, r, u.String(), http.StatusMovedPermanently)
267272 return
268273 }
internal/httpd/trailingslash_test.go +18
@@ -1,6 +1,7 @@
11package httpd
22
33import (
4 "bufio"
45 "net/http"
56 "net/http/httptest"
67 "strings"
@@ -37,6 +38,23 @@ func TestTrailingSlashRedirects(t *testing.T) {
3738 t.Errorf("%s: Location %q leaves the site", p, loc)
3839 }
3940 }
41 // An absolute-form request line, which RFC 7230 requires a server to
42 // accept, fills URL.Scheme and URL.Host. Neither may reach Location.
43 // ReadRequest also takes Host from the URL; it is pinned back to the
44 // site so this stays a test of the forge handler rather than of the
45 // pages router.
46 raw := "GET http://evil.example/cmc/ HTTP/1.1\r\nHost: forge.test\r\n\r\n"
47 abs, err := http.ReadRequest(bufio.NewReader(strings.NewReader(raw)))
48 if err != nil {
49 t.Fatal(err)
50 }
51 abs.Host = "forge.test"
52 aw := httptest.NewRecorder()
53 h.ServeHTTP(aw, abs)
54 if loc := aw.Header().Get("Location"); loc != "/cmc" {
55 t.Errorf("absolute-form request: Location %q, want %q", loc, "/cmc")
56 }
57
4058 r := httptest.NewRequest("POST", "/cmc/", nil)
4159 r.Host = "forge.test"
4260 w := httptest.NewRecorder()