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 { |
| 263 | 263 | // The fallback itself is registered at "/", so a miss |
| 264 | 264 | // reports that pattern; a real route reports its own. |
| 265 | 265 | 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 |
| 266 | 271 | http.Redirect(w, r, u.String(), http.StatusMovedPermanently) |
| 267 | 272 | return |
| 268 | 273 | } |
internal/httpd/trailingslash_test.go
+18
| @@ -1,6 +1,7 @@ |
| 1 | 1 | package httpd |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "bufio" |
| 4 | 5 | "net/http" |
| 5 | 6 | "net/http/httptest" |
| 6 | 7 | "strings" |
| @@ -37,6 +38,23 @@ func TestTrailingSlashRedirects(t *testing.T) { |
| 37 | 38 | t.Errorf("%s: Location %q leaves the site", p, loc) |
| 38 | 39 | } |
| 39 | 40 | } |
| 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 | |
| 40 | 58 | r := httptest.NewRequest("POST", "/cmc/", nil) |
| 41 | 59 | r.Host = "forge.test" |
| 42 | 60 | w := httptest.NewRecorder() |