Files
minstrel/internal/server/hygiene.go
T
bvandeusenandClaude Opus 5.5 d411693bb2
test-web / test (push) Successful in 55s
test-go / test (push) Successful in 1m14s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 2m50s
test-go / integration (push) Canceled after 2m50s
feat(server): security headers and a hash-based CSP for the web app (M462 #4980)
There were no security headers at all. Now:
- every response: nosniff, Referrer-Policy strict-origin-when-cross-origin,
  Permissions-Policy (no camera/mic/geolocation), X-Frame-Options DENY;
  each set only if the handler hasn't.
- HSTS only when the trusted proxy reports HTTPS (rule 94); never a redirect.
- index.html carries a Content-Security-Policy whose script-src is 'self'
  plus the sha256 of each inline script in the page as served, computed
  after the branding template runs. No 'unsafe-inline' or 'unsafe-eval'
  for scripts. img-src admits remote https/http because Lidarr suggestion
  art is a remote poster URL.

Hashing in Go rather than via SvelteKit's kit.csp covers the inline scripts
SvelteKit doesn't know about (app.html's theme bootstrap and the branding
global injected at build) and stays correct whatever the app name is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:29:26 -04:00

155 lines
5.5 KiB
Go

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()
}