Files
minstrel/internal/subsonic/subsonic.go
T
bvandeusenandClaude Opus 5.5 3bfddd0862
test-go / test (push) Successful in 1m54s
test-web / test (push) Successful in 1m34s
test-go / integration (push) Successful in 4m56s
android / Build + lint + test (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m52s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
feat(auth): throttle login, register, password reset and Subsonic auth failures (M462 #4976)
Every password-shaped check was mounted bare, so guessing was limited only
by bcrypt cost. A shared in-memory AttemptLimiter now sits in front of them:

- login: 10 failures per account and 50 per address per 15 min, checked
  before the user lookup and bcrypt; 429 with Retry-After. A success clears
  the account's count but not the address's.
- unknown usernames run a dummy bcrypt compare, so timing no longer says
  which accounts exist.
- register: 10 per address per hour; forgot-password: 5 per address and 3
  per email per hour (applied whether or not the email matches); reset: 20
  failed tokens per address per 15 min.
- Subsonic /rest: same limits as login, counting only wrong credentials,
  since clients authenticate on every request.

Web login, register, reset and forgot-password screens say how long to
wait; web and Android carry copy for the rate_limited code.

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

68 lines
2.7 KiB
Go

// Package subsonic implements a Subsonic-compatible REST API surface under
// /rest. It exists for third-party Subsonic clients (Symfonium, Substreamer,
// DSub, Tempo, play:Sub, …). Minstrel's own clients use the native /api/*
// surface, which has modern auth; /rest/* retains Subsonic's legacy auth for
// compatibility only. See docs/auth.md for the security posture.
package subsonic
import (
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
// Mount attaches Subsonic handlers at /rest on r. Endpoints are exposed at
// both /rest/foo and /rest/foo.view because client conventions vary. Both
// GET and POST are accepted; Subsonic's auth params live in the query string
// either way.
//
// hops is the live trusted-proxy depth, read per request so an admin change
// applies without a restart; it locates the caller for the auth throttle.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, events *playevents.Writer, hops func() int) {
b := &browseHandlers{pool: pool}
m := newMediaHandlers(pool, events, logger)
r.Route("/rest", func(sub chi.Router) {
sub.Use(Middleware(pool, cfg, auth.NewLoginGuard(), hops))
register(sub, "/ping", handlePing)
register(sub, "/getLicense", handleGetLicense)
register(sub, "/getMusicFolders", b.getMusicFolders)
register(sub, "/getIndexes", b.getIndexes)
register(sub, "/getArtists", b.getArtists)
register(sub, "/getArtist", b.getArtist)
register(sub, "/getAlbum", b.getAlbum)
register(sub, "/getAlbumList2", b.getAlbumList2)
register(sub, "/getSong", b.getSong)
register(sub, "/search3", b.search3)
register(sub, "/stream", m.handleStream)
register(sub, "/download", m.handleDownload)
register(sub, "/getCoverArt", m.handleGetCoverArt)
register(sub, "/scrobble", m.handleScrobble)
register(sub, "/star", m.handleStar)
register(sub, "/unstar", m.handleUnstar)
register(sub, "/getStarred", m.handleGetStarred)
register(sub, "/getStarred2", m.handleGetStarred2)
register(sub, "/getUser", handleGetUser)
// Subsonic clients expect every /rest/* miss to come back as a
// Subsonic-shaped error envelope, not chi's plain-text 404. Without
// this, hitting an unimplemented method (Feishin's first-login probe
// hits getUser, getPlaylists, etc.) breaks the client's parser and
// surfaces as a generic "failed to log in".
sub.NotFound(func(w http.ResponseWriter, r *http.Request) {
WriteFail(w, r, ErrGeneric, "Method not implemented")
})
})
}
func register(r chi.Router, path string, h func(http.ResponseWriter, *http.Request)) {
r.Get(path, h)
r.Post(path, h)
r.Get(path+".view", h)
r.Post(path+".view", h)
}