package server import ( "io" "mime" "net/http" "strings" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" ) // maxRequestBody caps every request body. The largest legitimate one is a // playlist save of a few thousand track ids, well under 1 MiB; 4 MiB leaves // room without letting one request hold arbitrary memory. const maxRequestBody = 4 << 20 // bodyReadTimeout bounds how long a client may take to send a request body. const bodyReadTimeout = 30 * time.Second // limitRequestBody caps the body size and the time allowed to deliver it. // // Why not http.Server.ReadTimeout: that deadline stays armed for the whole // request, and once a handler has consumed the body, net/http's background // read hits it and cancels the request context. That would cut off audio // streams and the SSE event stream at the timeout. Here the deadline is set // only on requests that carry a body, and cleared the moment the body has // been read, so long-running responses are never affected. func limitRequestBody(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Body == nil || r.Body == http.NoBody { next.ServeHTTP(w, r) return } rc := http.NewResponseController(w) // Best-effort: a ResponseWriter that can't set deadlines (a test // recorder) still gets the size limit. armed := rc.SetReadDeadline(time.Now().Add(bodyReadTimeout)) == nil body := http.MaxBytesReader(w, r.Body, maxRequestBody) if armed { body = &deadlineClearingBody{ReadCloser: body, rc: rc} } r.Body = body next.ServeHTTP(w, r) }) } // deadlineClearingBody lifts the read deadline once the body is exhausted. // A deadline change applies to pending reads too, so this also releases the // background read net/http starts at end-of-body. type deadlineClearingBody struct { io.ReadCloser rc *http.ResponseController cleared bool } func (b *deadlineClearingBody) Read(p []byte) (int, error) { n, err := b.ReadCloser.Read(p) if err != nil && !b.cleared { b.cleared = true _ = b.rc.SetReadDeadline(time.Time{}) } return n, err } func (b *deadlineClearingBody) Close() error { if !b.cleared { b.cleared = true _ = b.rc.SetReadDeadline(time.Time{}) } return b.ReadCloser.Close() } // requireJSONForCookieWrites refuses a state-changing /api request that is // authenticated by the session cookie unless its body is JSON. // // SameSite=Strict already keeps the cookie off cross-site requests. This is // the backstop for the case SameSite cannot see: a sibling app on the same // registrable domain (another *.fabledsword.com service) counts as same-site. // An HTML form or a no-preflight fetch can only send form-encoded, multipart // or text/plain bodies, so demanding application/json closes that path. // Bearer-token requests and /rest (query-string auth) carry nothing a browser // attaches on its own, so they are not checked. The Android app does send // the cookie, but every body it sends is JSON (Retrofit's kotlinx converter) // and its bodiless writes carry no Content-Type, so it passes unchanged. func requireJSONForCookieWrites(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: next.ServeHTTP(w, r) return } if !strings.HasPrefix(r.URL.Path, "/api/") { next.ServeHTTP(w, r) return } if c, err := r.Cookie(auth.SessionCookieName); err != nil || c.Value == "" { next.ServeHTTP(w, r) return } ct := r.Header.Get("Content-Type") if ct == "" && (r.ContentLength == 0 || r.Body == nil || r.Body == http.NoBody) { // No body, no content type: nothing a form could have sent. next.ServeHTTP(w, r) return } if mt, _, err := mime.ParseMediaType(ct); err != nil || mt != "application/json" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnsupportedMediaType) _, _ = io.WriteString(w, `{"error":{"code":"unsupported_media_type","message":"request body must be application/json"}}`) return } next.ServeHTTP(w, r) }) } // securityHeaders sets the response headers every response should carry. // Each is set only when the handler hasn't, so a route with a reason to // differ keeps its own value. The document's Content-Security-Policy is set // by the SPA handler, which knows the inline-script hashes. // // HSTS goes out only when the request reached us over HTTPS as the trusted // proxy reports it (rule 94): sending it over plain HTTP is ignored by // browsers at best, and the app never forces HTTPS. func securityHeaders(hops func() int) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() setDefault(h, "X-Content-Type-Options", "nosniff") setDefault(h, "Referrer-Policy", "strict-origin-when-cross-origin") setDefault(h, "Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()") // frame-ancestors in the document's CSP covers modern browsers; // this covers the rest, and every non-HTML response. setDefault(h, "X-Frame-Options", "DENY") if auth.IsHTTPS(r, hopsOrZero(hops)) { setDefault(h, "Strict-Transport-Security", "max-age=31536000") } next.ServeHTTP(w, r) }) } } func setDefault(h http.Header, key, value string) { if h.Get(key) == "" { h.Set(key, value) } } func hopsOrZero(hops func() int) int { if hops == nil { return 0 } return hops() }