Compare commits

...
3 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 24b767c23b feat(server): cap request bodies, bound body reads, private cache headers, JSON-only cookie writes (M462 #4979)
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
- 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
bvandeusenandClaude Opus 5.5 755f997b0d feat(auth): sessions expire server-side; password change and reset end other sessions (M462 #4978)
Sessions had no server-side expiry: only the web cookie's 30-day Max-Age
limited them, and a bearer token (Android) lived until revoked by hand.
GetSessionByTokenHash and ListSessionsForUser now ignore sessions idle for
30 days or older than a year, and the GC worker deletes them hourly.

A password change was a plain UPDATE, so a session opened with the old
password survived it. Now:
- self-service change signs out every other device and keeps this one;
- reset by email ends every session the account has;
- an admin reset ends the target's sessions (keeping the admin's own when
  they reset themselves).

The success copy on web and Android says the other devices were signed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:32:39 -04:00
bvandeusenandClaude Opus 5.5 719dc62b0d fix(auth): set the session cookie's Secure flag behind a TLS-terminating proxy (M462 #4977)
The cookie set Secure from r.TLS, which is nil whenever TLS terminates at
Traefik or Cloudflare, so a public deployment's session cookie went out
without Secure. auth.IsHTTPS now decides it from X-Forwarded-Proto, believed
only through the trusted-hop count (rule 94) and read positionally like
X-Forwarded-For. Plain-HTTP and LAN logins are unchanged; nothing redirects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:30:46 -04:00
21 changed files with 552 additions and 12 deletions
@@ -57,7 +57,7 @@ class PasswordViewModel @Inject constructor(
try {
repository.changePassword(current = s.current, next = s.next)
internal.update {
PasswordUiState(message = "Password changed.")
PasswordUiState(message = "Password changed. Your other devices have been signed out.")
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
+5
View File
@@ -385,6 +385,11 @@ func run() error {
Addr: cfg.Server.Address,
Handler: srv.Router(),
ReadHeaderTimeout: 10 * time.Second,
// Closes keep-alive connections nobody is using. Deliberately no
// ReadTimeout or WriteTimeout: either would cut off audio streams and
// the SSE event stream. Request bodies get their own deadline in the
// server's limitRequestBody middleware instead.
IdleTimeout: 120 * time.Second,
}
errCh := make(chan error, 1)
+13
View File
@@ -285,6 +285,19 @@ func (h *handlers) handleAdminResetPassword(w http.ResponseWriter, r *http.Reque
return
}
// The target's existing sessions end with the old password. An admin
// resetting their own password keeps the session they're using, the
// same as a self-service change.
sessID, ownSession := auth.SessionIDFromContext(r.Context())
if targetID == caller.ID && ownSession {
_, err = q.DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{UserID: targetID, ID: sessID})
} else {
_, err = q.DeleteSessionsForUser(r.Context(), targetID)
}
if err != nil {
h.logger.Error("admin reset password: revoke sessions failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, caller.ID, targetID, audit.ActionPasswordResetAdmin, nil)
w.WriteHeader(http.StatusNoContent)
+1 -1
View File
@@ -140,7 +140,7 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil, // dev over http stays functional; prod over https gets Secure
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional (rule 94)
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionCookieMaxAge.Seconds()),
})
+1 -1
View File
@@ -195,7 +195,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
Value: sessionToken,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional (rule 94)
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionCookieMaxAge.Seconds()),
})
+7
View File
@@ -104,6 +104,13 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
return
}
// End every session the account has. Whoever needed a reset isn't signed
// in anywhere they rely on, and whoever may have learned the old password
// must not stay signed in on it.
if _, err := q.DeleteSessionsForUser(r.Context(), reset.UserID); err != nil {
h.logger.Error("reset password: revoke sessions failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, reset.UserID, reset.UserID, audit.ActionPasswordResetByEmail, nil)
w.WriteHeader(http.StatusNoContent)
+14
View File
@@ -8,6 +8,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
@@ -61,6 +62,19 @@ func (h *handlers) handleChangePassword(w http.ResponseWriter, r *http.Request)
return
}
// Sign out every other device. A password change is often the response
// to suspecting someone else has it, and their session would otherwise
// outlive the password it was opened with. The device making the change
// stays signed in.
if sessID, ok := auth.SessionIDFromContext(r.Context()); ok {
if _, err := q.DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{
UserID: user.ID,
ID: sessID,
}); err != nil {
h.logger.Error("change password: revoke other sessions failed", "err", err)
}
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionPasswordChangeSelf, nil)
w.WriteHeader(http.StatusNoContent)
+2 -2
View File
@@ -117,7 +117,7 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
// clients skip the conditional GET for the bulk of a session, but
// stale art clears within 24h after a re-scan. ServeContent below
// still emits Last-Modified for the conditional path when needed.
w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
w.Header().Set("Cache-Control", "private, max-age=86400, must-revalidate")
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}
@@ -197,6 +197,6 @@ func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
// max-age + immutable lets the client cache (LockCachingAudioSource
// on the Flutter side, browser cache on web) skip even the
// conditional GET on repeat plays.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
}
+1 -1
View File
@@ -406,7 +406,7 @@ func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request
// (system playlists re-rendered on rebuild, user playlists when
// modified). 5 minutes is short enough for normal edits to feel
// fresh, long enough to skip repeat fetches during a session.
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
w.Header().Set("Cache-Control", "private, max-age=300, must-revalidate")
http.ServeFile(w, r, full)
}
+139
View File
@@ -0,0 +1,139 @@
package api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func sessionExists(t *testing.T, pool *pgxpool.Pool, id pgtype.UUID) bool {
t.Helper()
var exists bool
if err := pool.QueryRow(context.Background(),
`SELECT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, id,
).Scan(&exists); err != nil {
t.Fatalf("exists check: %v", err)
}
return exists
}
// Sessions expire server-side on two limits, and an expired one stops
// authenticating at once rather than when the GC sweep gets to it.
func TestSessionExpiry_IdleAndAbsoluteLimits(t *testing.T) {
_, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
q := dbq.New(pool)
mint := func(setup string) []byte {
token, err := auth.MintSessionToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
hash := auth.HashSessionToken(token)
sess, err := q.InsertSession(context.Background(), dbq.InsertSessionParams{
UserID: user.ID, TokenHash: hash, UserAgent: "test", Ip: "192.0.2.1",
})
if err != nil {
t.Fatalf("insert: %v", err)
}
if setup != "" {
if _, err := pool.Exec(context.Background(), `UPDATE sessions SET `+setup+` WHERE id = $1`, sess.ID); err != nil {
t.Fatalf("age session: %v", err)
}
}
return hash
}
fresh := mint("")
idle := mint(`last_seen_at = now() - interval '31 days'`)
old := mint(`created_at = now() - interval '366 days'`)
if _, err := q.GetSessionByTokenHash(context.Background(), fresh); err != nil {
t.Errorf("fresh session: %v, want found", err)
}
for name, hash := range map[string][]byte{"idle": idle, "absolute": old} {
if _, err := q.GetSessionByTokenHash(context.Background(), hash); !errors.Is(err, pgx.ErrNoRows) {
t.Errorf("%s-expired session: err = %v, want ErrNoRows", name, err)
}
}
listed, err := q.ListSessionsForUser(context.Background(), user.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(listed) != 1 {
t.Errorf("listed %d sessions, want only the live one", len(listed))
}
swept, err := q.GcDeleteExpiredSessions(context.Background())
if err != nil {
t.Fatalf("gc: %v", err)
}
if swept != 2 {
t.Errorf("gc swept %d, want the 2 expired rows", swept)
}
}
// Changing your password signs out every other device and keeps this one.
func TestHandleChangePassword_RevokesOtherSessions(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
current := seedSession(t, pool, user.ID, "192.0.2.1")
other := seedSession(t, pool, user.ID, "198.51.100.7")
body := strings.NewReader(`{"current_password":"hunter2","new_password":"correct-horse"}`)
req := httptest.NewRequest(http.MethodPut, "/api/me/password", body)
req.Header.Set("Content-Type", "application/json")
req = withSession(req, user, current)
w := httptest.NewRecorder()
h.handleChangePassword(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
if !sessionExists(t, pool, current) {
t.Error("the device that changed the password was signed out")
}
if sessionExists(t, pool, other) {
t.Error("another device's session survived the password change")
}
}
// A reset by email ends every session the account has.
func TestHandleResetPassword_RevokesAllSessions(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
s1 := seedSession(t, pool, user.ID, "192.0.2.1")
s2 := seedSession(t, pool, user.ID, "198.51.100.7")
const token = "reset-token-for-session-lifecycle-test"
if _, err := pool.Exec(context.Background(),
`INSERT INTO password_resets (token, user_id, expires_at) VALUES ($1, $2, now() + interval '1 hour')`,
token, user.ID,
); err != nil {
t.Fatalf("seed reset: %v", err)
}
body := strings.NewReader(`{"token":"` + token + `","new_password":"correct-horse"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleResetPassword(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
if sessionExists(t, pool, s1) || sessionExists(t, pool, s2) {
t.Error("a session survived the password reset")
}
}
+30
View File
@@ -65,6 +65,36 @@ func ClientIP(r *http.Request, trustedProxyHops int) string {
return remote
}
// IsHTTPS reports whether the client reached us over HTTPS, reading through
// trustedProxyHops reverse proxies by the same rule as ClientIP.
//
// TLS terminates at the operator's proxy (rule 94), so r.TLS is nil in every
// real deployment and only X-Forwarded-Proto can say what the client used.
// That header is believed only when hops >= 1: with no trusted proxy it is
// just something any client can send. Proxies that append rather than
// overwrite produce a comma list, read positionally like X-Forwarded-For.
//
// This decides only HTTPS-only behaviour (the cookie Secure flag, HSTS). It
// must never drive a redirect: plain-HTTP and LAN use keep working.
func IsHTTPS(r *http.Request, trustedProxyHops int) bool {
if r.TLS != nil {
return true
}
if trustedProxyHops <= 0 {
return false
}
raw := r.Header.Get("X-Forwarded-Proto")
if strings.TrimSpace(raw) == "" {
return false
}
parts := strings.Split(raw, ",")
idx := len(parts) - trustedProxyHops
if idx < 0 {
idx = 0
}
return strings.EqualFold(strings.TrimSpace(parts[idx]), "https")
}
// forwardedChain returns the X-Forwarded-For entries in wire order, or the
// single X-Real-IP value when XFF is absent.
//
+43
View File
@@ -1,6 +1,7 @@
package auth
import (
"crypto/tls"
"net/http"
"testing"
)
@@ -168,3 +169,45 @@ func TestClientIP(t *testing.T) {
})
}
}
func TestIsHTTPS(t *testing.T) {
tests := []struct {
name string
hops int
proto string
tls bool
want bool
}{
// The three cases rule 94 names.
{name: "hops 0 ignores a forwarded https", hops: 0, proto: "https", want: false},
{name: "hops 1 believes a forwarded https", hops: 1, proto: "https", want: true},
{name: "plain request is not https", hops: 1, want: false},
{name: "forwarded http is not https", hops: 1, proto: "http", want: false},
{name: "case-insensitive", hops: 1, proto: "HTTPS", want: true},
{name: "direct TLS is https whatever the hops", hops: 0, tls: true, want: true},
// An appending chain is read positionally: with one trusted hop the
// last entry is our proxy's word, and a client-supplied first entry
// cannot flip it.
{name: "appended chain reads our proxy's entry", hops: 1, proto: "https, http", want: false},
{name: "two hops read the CDN's entry", hops: 2, proto: "https, http", want: true},
{name: "over-counted hops clamp to the first entry", hops: 5, proto: "https", want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "/api/auth/login", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
if tc.proto != "" {
r.Header.Set("X-Forwarded-Proto", tc.proto)
}
if tc.tls {
r.TLS = &tls.ConnectionState{}
}
if got := IsHTTPS(r, tc.hops); got != tc.want {
t.Errorf("IsHTTPS(hops=%d, proto=%q) = %v, want %v", tc.hops, tc.proto, got, tc.want)
}
})
}
}
+16
View File
@@ -84,6 +84,22 @@ func (q *Queries) GcDeleteExpiredPasswordResets(ctx context.Context) (int64, err
return result.RowsAffected(), nil
}
const gcDeleteExpiredSessions = `-- name: GcDeleteExpiredSessions :execrows
DELETE FROM sessions
WHERE last_seen_at <= now() - interval '30 days'
OR created_at <= now() - interval '365 days'
`
// Sessions past their idle (30 days) or absolute (1 year) limit. They already
// fail auth through GetSessionByTokenHash's filter; this only clears the rows.
func (q *Queries) GcDeleteExpiredSessions(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcDeleteExpiredSessions)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const gcExpireScrobbleQueueFailedRows = `-- name: GcExpireScrobbleQueueFailedRows :execrows
DELETE FROM scrobble_queue
WHERE status = 'failed'
+37 -3
View File
@@ -69,10 +69,38 @@ func (q *Queries) DeleteSessionForUser(ctx context.Context, arg DeleteSessionFor
return result.RowsAffected(), nil
}
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE token_hash = $1
const deleteSessionsForUser = `-- name: DeleteSessionsForUser :execrows
DELETE FROM sessions WHERE user_id = $1
`
// Every session the user has, the caller's included. A password reset uses
// it: whoever forgot the password is not signed in anywhere they trust, and
// whoever may have learned it must be signed out everywhere.
func (q *Queries) DeleteSessionsForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, deleteSessionsForUser, userID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions
WHERE token_hash = $1
AND last_seen_at > now() - interval '30 days'
AND created_at > now() - interval '365 days'
`
// Expired sessions are invisible here, so they fail auth the moment they
// lapse rather than whenever the GC sweep next runs. Two limits:
//
// idle 30 days — a token nobody has used in a month is abandoned, and
// matches the web cookie's lifetime;
// absolute 1 year — even a token in daily use is re-issued yearly, so a
// stolen one that is being used quietly does not live
// forever.
//
// Keep in step with ListSessionsForUser and GcDeleteExpiredSessions.
func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) {
row := q.db.QueryRow(ctx, getSessionByTokenHash, tokenHash)
var i Session
@@ -127,11 +155,17 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (S
}
const listSessionsForUser = `-- name: ListSessionsForUser :many
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions
WHERE user_id = $1
AND last_seen_at > now() - interval '30 days'
AND created_at > now() - interval '365 days'
ORDER BY last_seen_at DESC
`
// Most-recently-active first: the row a user is most likely to act on is the
// one that moved last, and an unfamiliar entry at the top is the alarm.
// Expired rows are left out: they no longer authenticate, so listing them
// as active would be wrong. Same limits as GetSessionByTokenHash.
func (q *Queries) ListSessionsForUser(ctx context.Context, userID pgtype.UUID) ([]Session, error) {
rows, err := q.db.Query(ctx, listSessionsForUser, userID)
if err != nil {
+7
View File
@@ -68,3 +68,10 @@ UPDATE system_playlist_runs
DELETE FROM password_resets
WHERE (used_at IS NOT NULL AND used_at < now() - INTERVAL '7 days')
OR (used_at IS NULL AND expires_at < now() - INTERVAL '1 hour');
-- name: GcDeleteExpiredSessions :execrows
-- Sessions past their idle (30 days) or absolute (1 year) limit. They already
-- fail auth through GetSessionByTokenHash's filter; this only clears the rows.
DELETE FROM sessions
WHERE last_seen_at <= now() - interval '30 days'
OR created_at <= now() - interval '365 days';
+25 -2
View File
@@ -7,7 +7,18 @@ VALUES ($1, $2, $3, sqlc.arg(ip), sqlc.arg(ip))
RETURNING *;
-- name: GetSessionByTokenHash :one
SELECT * FROM sessions WHERE token_hash = $1;
-- Expired sessions are invisible here, so they fail auth the moment they
-- lapse rather than whenever the GC sweep next runs. Two limits:
-- idle 30 days — a token nobody has used in a month is abandoned, and
-- matches the web cookie's lifetime;
-- absolute 1 year — even a token in daily use is re-issued yearly, so a
-- stolen one that is being used quietly does not live
-- forever.
-- Keep in step with ListSessionsForUser and GcDeleteExpiredSessions.
SELECT * FROM sessions
WHERE token_hash = $1
AND last_seen_at > now() - interval '30 days'
AND created_at > now() - interval '365 days';
-- name: TouchSessionLastSeen :exec
UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1;
@@ -15,7 +26,13 @@ UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1;
-- name: ListSessionsForUser :many
-- Most-recently-active first: the row a user is most likely to act on is the
-- one that moved last, and an unfamiliar entry at the top is the alarm.
SELECT * FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC;
-- Expired rows are left out: they no longer authenticate, so listing them
-- as active would be wrong. Same limits as GetSessionByTokenHash.
SELECT * FROM sessions
WHERE user_id = $1
AND last_seen_at > now() - interval '30 days'
AND created_at > now() - interval '365 days'
ORDER BY last_seen_at DESC;
-- name: DeleteSession :exec
DELETE FROM sessions WHERE id = $1;
@@ -34,3 +51,9 @@ DELETE FROM sessions WHERE id = $1 AND user_id = $2;
-- "Log out everywhere else." Excludes the caller's own session so the action
-- doesn't log them out of the page they just used to invoke it.
DELETE FROM sessions WHERE user_id = $1 AND id <> $2;
-- name: DeleteSessionsForUser :execrows
-- Every session the user has, the caller's included. A password reset uses
-- it: whoever forgot the password is not signed in anywhere they trust, and
-- whoever may have learned it must be signed out everywhere.
DELETE FROM sessions WHERE user_id = $1;
+2
View File
@@ -16,6 +16,7 @@
// - GcExpireScrobbleQueueFailedRows (#567)
// - GcResetStuckSystemPlaylistRuns (#574)
// - GcDeleteExpiredPasswordResets (#575)
// - GcDeleteExpiredSessions (M462 #4978 — idle 30d / absolute 1y)
// - GcPruneDiagnostics (M9 — diagnostics 30d retention)
// - GcDeleteExpiredSuggestionSnoozes (#2374 — snoozes expire, then go)
// - GcDeleteOrphanedCandidateArtistTags(+State) (#2376 — the similarity
@@ -86,6 +87,7 @@ func (w *Worker) tickOnce(ctx context.Context) {
w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows)
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
w.runSweep(ctx, "delete_expired_sessions", q.GcDeleteExpiredSessions)
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
w.runSweep(ctx, "delete_expired_suggestion_snoozes", q.GcDeleteExpiredSuggestionSnoozes)
// Tags before state: if the process dies between the two, a candidate left
+115
View File
@@ -0,0 +1,115 @@
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)
})
}
+90
View File
@@ -0,0 +1,90 @@
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)
}
})
}
}
+2
View File
@@ -139,6 +139,8 @@ func (s *Server) Router() http.Handler {
r.Use(middleware.RequestID)
r.Use(requestLog(s.Logger, netSettings.Hops))
r.Use(middleware.Recoverer)
r.Use(limitRequestBody)
r.Use(requireJSONForCookieWrites)
r.Get("/healthz", s.handleHealthz)
+1 -1
View File
@@ -153,7 +153,7 @@
passwordSaving = true;
try {
await changePassword(passwordForm.current, passwordForm.new);
pushToast('Password changed.');
pushToast('Password changed. Your other devices have been signed out.');
passwordForm = { current: '', new: '', confirm: '' };
} catch (e: unknown) {
const code = errCode(e);