Files
minstrel/internal/server/hygiene_test.go
T
bvandeusenandClaude Opus 5.5 24b767c23b
test-go / test (push) Successful in 1m48s
test-web / test (push) Successful in 2m0s
android / Build + lint + test (push) Successful in 6m27s
release / Build signed APK (releases and dev) (push) Successful in 6m42s
test-go / integration (push) Successful in 4m55s
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
feat(server): cap request bodies, bound body reads, private cache headers, JSON-only cookie writes (M462 #4979)
- Every request body is capped at 4 MiB and must arrive within 30s. The
  deadline is set per request and cleared at end of body rather than via
  http.Server.ReadTimeout, which would cancel audio streams and the SSE
  stream once the background read hit it.
- IdleTimeout 120s closes idle keep-alive connections. Still no global
  WriteTimeout, for the same streaming reason.
- Streams, album covers and playlist covers are Cache-Control: private, so
  a shared cache never keeps an authenticated response for others.
- A cookie-authenticated write to /api must be application/json (415
  otherwise). SameSite=Strict can't see a sibling app on the same
  registrable domain; forms and no-preflight fetches can't send JSON.

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

91 lines
3.3 KiB
Go

package server
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)
func okHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
if _, err := io.ReadAll(r.Body); err != nil {
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
return
}
}
w.WriteHeader(http.StatusNoContent)
})
}
func TestLimitRequestBody_RejectsOversizedBody(t *testing.T) {
h := limitRequestBody(okHandler())
big := strings.NewReader(strings.Repeat("x", maxRequestBody+1))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/playlists", big))
if w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("oversized body: status = %d, want the handler's read to fail", w.Code)
}
small := strings.NewReader(`{"name":"ok"}`)
w = httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/playlists", small))
if w.Code != http.StatusNoContent {
t.Errorf("small body: status = %d, want 204", w.Code)
}
}
// The cases a cross-site form or no-preflight fetch could produce must be
// refused when the session cookie rides along; everything a real client
// sends must pass.
func TestRequireJSONForCookieWrites(t *testing.T) {
cookie := &http.Cookie{Name: auth.SessionCookieName, Value: "tok"}
tests := []struct {
name string
method string
path string
contentType string
body string
withCookie bool
want int
}{
{"form post with cookie", http.MethodPost, "/api/me/password", "application/x-www-form-urlencoded", "a=b", true, http.StatusUnsupportedMediaType},
{"text/plain post with cookie", http.MethodPost, "/api/me/password", "text/plain", `{"a":1}`, true, http.StatusUnsupportedMediaType},
{"multipart with cookie", http.MethodPut, "/api/me/profile", "multipart/form-data; boundary=x", "--x--", true, http.StatusUnsupportedMediaType},
{"body without content type", http.MethodPost, "/api/me/profile", "", `{"a":1}`, true, http.StatusUnsupportedMediaType},
{"json with cookie", http.MethodPost, "/api/me/password", "application/json", `{}`, true, http.StatusNoContent},
{"json with charset", http.MethodPost, "/api/me/password", "application/json; charset=utf-8", `{}`, true, http.StatusNoContent},
{"bodiless delete with cookie", http.MethodDelete, "/api/me/sessions/1", "", "", true, http.StatusNoContent},
{"form post without cookie (bearer client)", http.MethodPost, "/api/me/password", "text/plain", "x", false, http.StatusNoContent},
{"subsonic post is not /api", http.MethodPost, "/rest/scrobble", "application/x-www-form-urlencoded", "id=1", true, http.StatusNoContent},
{"GET is never checked", http.MethodGet, "/api/me", "text/plain", "", true, http.StatusNoContent},
}
h := requireJSONForCookieWrites(okHandler())
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var body io.Reader
if tc.body != "" {
body = strings.NewReader(tc.body)
}
req := httptest.NewRequest(tc.method, tc.path, body)
if tc.contentType != "" {
req.Header.Set("Content-Type", tc.contentType)
}
if tc.withCookie {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != tc.want {
t.Errorf("status = %d, want %d", w.Code, tc.want)
}
})
}
}