The limiter covered only /api/v1/auth/login. Jellyfin's AuthenticateByName and Subsonic's authenticate both called auth.Authenticate with no limiting, so an attacker brute-forced through those instead: the same password, the same argon2, no ceiling. The limiter moves from auth.Handlers onto *Authenticator, which all three surfaces already hold, so they share one budget and splitting guesses across surfaces gains nothing. main.go builds one authenticator and hands it to both compat layers, so the sharing is real rather than intended. Subsonic needs care: it has no login step, authenticate runs on every request, so a naive check would charge playback against the login budget and lock a listener out mid-album. The limiter is consulted only when a credential was actually supplied, and a success or an uncredentialed request spends nothing. Verified red-green on all three: removing the Jellyfin gate fails TestAuthenticateByNameIsRateLimited with 401 instead of 429; removing the Subsonic credential guard fails TestSuccessAndUncredentialedDoNotConsumeTheBudget; neutering AllowLogin or ResetLogin fails the auth tests. Known gap, unchanged: the limiter is in-process, so it resets on restart and does not span replicas. Fine for one binary against one SQLite file.
130 lines
3.6 KiB
Go
130 lines
3.6 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/mithral/vellum/internal/apitoken"
|
|
"github.com/mithral/vellum/internal/auth"
|
|
)
|
|
|
|
// authenticate resolves the Subsonic credentials on a request to a user.
|
|
//
|
|
// Every accepted form is checked against the user's recoverable Subsonic key
|
|
// (apitoken.Plain), which is the only credential this protocol can verify:
|
|
//
|
|
// - t = md5(key + s), with s the salt — the preferred form.
|
|
// - p = key directly (Navidrome accepts this, and so must we for older
|
|
// clients).
|
|
// - apiKey = key directly, the OpenSubsonic extension.
|
|
// - token = key-derived md5 with s — the same as t under the alternate
|
|
// parameter name some clients use.
|
|
//
|
|
// A missing user, a user with no Subsonic key, or a wrong credential all
|
|
// collapse to "not authenticated": the caller must not be able to tell them
|
|
// apart, and the response is always error code 40.
|
|
func (h *Handlers) authenticate(r *http.Request) (_ auth.User, ok bool) {
|
|
ctx := r.Context()
|
|
q := r.FormValue
|
|
|
|
username := strings.TrimSpace(q("u"))
|
|
apiKey := strings.TrimSpace(q("apiKey"))
|
|
|
|
// Subsonic has no login step: authenticate runs on EVERY request, so the
|
|
// login limiter is consulted only when a credential was actually supplied.
|
|
// Charging playback against the login budget would lock out a client that
|
|
// is simply using the server, and a request with no credentials must not
|
|
// touch the limiter at all.
|
|
if username != "" && (q("p") != "" || q("t") != "" || q("token") != "" || apiKey != "") {
|
|
ip := h.auth.ClientIP(r)
|
|
if !h.auth.AllowLogin(ip, username) {
|
|
return auth.User{}, false // The caller answers with code 40.
|
|
}
|
|
defer func() {
|
|
if ok {
|
|
h.auth.ResetLogin(ip, username)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// apiKey auth carries no username; resolve the key to its owner.
|
|
if username == "" {
|
|
if apiKey == "" {
|
|
return auth.User{}, false
|
|
}
|
|
userID, err := apitoken.Resolve(ctx, h.db, apiKey)
|
|
if err != nil {
|
|
return auth.User{}, false
|
|
}
|
|
u, err := auth.GetUserByID(ctx, h.db, userID)
|
|
if err != nil {
|
|
return auth.User{}, false
|
|
}
|
|
return u, true
|
|
}
|
|
|
|
u, err := auth.GetUserByUsername(ctx, h.db, username)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return auth.User{}, false
|
|
}
|
|
if err != nil {
|
|
h.log.Error("subsonic: load user", "err", err)
|
|
return auth.User{}, false
|
|
}
|
|
|
|
key, err := apitoken.Plain(ctx, h.db, u.ID)
|
|
if errors.Is(err, apitoken.ErrNotFound) {
|
|
// The user has no Subsonic key yet; they must mint one from the web
|
|
// UI. Treated as a credential failure, not a server error.
|
|
return auth.User{}, false
|
|
}
|
|
if err != nil {
|
|
h.log.Error("subsonic: load key", "err", err)
|
|
return auth.User{}, false
|
|
}
|
|
|
|
// p = the key directly. "enc:" is the spec's hex-encoded form.
|
|
if p := q("p"); p != "" {
|
|
if raw, ok := strings.CutPrefix(p, "enc:"); ok {
|
|
decoded, err := hex.DecodeString(raw)
|
|
if err == nil {
|
|
p = string(decoded)
|
|
}
|
|
}
|
|
if constantTimeEqual(p, key) {
|
|
return u, true
|
|
}
|
|
}
|
|
|
|
// apiKey alongside u: same direct comparison.
|
|
if apiKey != "" && constantTimeEqual(apiKey, key) {
|
|
return u, true
|
|
}
|
|
|
|
// t (or token) = md5(key + s).
|
|
token := q("t")
|
|
if token == "" {
|
|
token = q("token")
|
|
}
|
|
salt := q("s")
|
|
if token != "" && salt != "" {
|
|
sum := md5.Sum([]byte(key + salt))
|
|
want := hex.EncodeToString(sum[:])
|
|
if constantTimeEqual(strings.ToLower(token), want) {
|
|
return u, true
|
|
}
|
|
}
|
|
|
|
return auth.User{}, false
|
|
}
|
|
|
|
// constantTimeEqual compares two credential strings without leaking their
|
|
// common prefix through timing.
|
|
func constantTimeEqual(a, b string) bool {
|
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
|
}
|