navidrome/server/subsonic/middlewares.go
Deluan Quintão c3b9b4ecb3
fix(subsonic): rate limit failed authentication attempts (#6185)
* fix(subsonic): limit failed authentication attempts per client IP and username

The Subsonic API checked credentials on every request with no limit on failures, so any
account could be brute-forced over /rest/*. Failed u+p, t+s and jwt attempts are now capped
per (client IP, lower-cased username) using AuthRequestLimit and AuthWindowLength, the same
settings that guard the UI login. Every request carries credentials, so only failures count:
a slot is taken before the check and given back on success or on a server error, which also
stops concurrent guesses from overshooting the limit.

Blocked attempts get the same response as a wrong password (HTTP 200, error code 40, no
Retry-After), so an attacker cannot tell a block from a wrong guess. Reverse proxy and
internal authentication are not limited. The client IP helper behind ClientIPRateLimiter is
now exported as server.ClientIP, so spoofed forwarding headers cannot open a fresh bucket.

* refactor(subsonic): simplify failed authentication limiter

Release the limiter slot from a single place in authenticate(), after the user lookup and
credential check, instead of separately in the canceled branch. Store attempt counters by
value instead of by pointer, and drop limiter unit tests that only repeated the middleware
specs.

* fix(subsonic): wait for an in-flight auth check instead of rejecting

Slots were reserved before the credential check and only released afterwards, so once
AuthRequestLimit checks for the same client IP and username overlapped, the next request was
answered with error code 40 even when its credentials were valid. Clients that fan out parallel
requests hit this constantly: a burst of six valid logins lost one, a burst of fifty lost forty
five, and the web UI authenticates its own /rest calls the same way.

A key now carries a slot channel of AuthRequestLimit capacity, and a request waits on it rather
than failing when other checks for that key are in flight. Failures are recorded after the check,
and a request is only rejected when the key already reached the limit within the window. A waiting
request gives up if its context is canceled. Concurrent guesses still cannot run unchecked: at most
AuthRequestLimit checks run at once and the rest are turned away as soon as the failures land.

* docs(subsonic): state the real guess ceiling of the auth limiter

The comment claimed a burst cannot overshoot, which reads as a hard cap of AuthRequestLimit. Allowing concurrent checks means a window admits up to 2*limit-1 guesses, so say that instead.
2026-09-20 22:02:23 -04:00

308 lines
9.0 KiB
Go

package subsonic
import (
"cmp"
"context"
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5/middleware"
ua "github.com/mileusna/useragent"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/req"
)
func postFormToQueryParams(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 10<<20) // 10MB
err := r.ParseForm()
if err != nil {
sendError(w, r, newError(responses.ErrorGeneric, err.Error()))
return
}
var parts []string
for key, values := range r.Form {
for _, v := range values {
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v))
}
}
r.URL.RawQuery = strings.Join(parts, "&")
next.ServeHTTP(w, r)
})
}
func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
username := server.InternalAuth(r)
// If the username comes from internal auth, do not also do reverse proxy auth, as
// the request will have no reverse proxy IP
if username != "" {
return username, true
}
return server.UsernameFromExtAuthHeader(r), false
}
func checkRequiredParameters(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var requiredParameters []string
username, _ := fromInternalOrProxyAuth(r)
if username != "" {
requiredParameters = []string{"v", "c"}
} else {
requiredParameters = []string{"u", "v", "c"}
}
p := req.Params(r)
for _, param := range requiredParameters {
if _, err := p.String(param); err != nil {
log.Warn(r, err)
sendError(w, r, err)
return
}
}
if username == "" {
username, _ = p.String("u")
}
client, _ := p.String("c")
version, _ := p.String("v")
ctx := r.Context()
ctx = request.WithUsername(ctx, username)
ctx = request.WithClient(ctx, client)
ctx = request.WithVersion(ctx, version)
log.Debug(ctx, "API: New request "+r.URL.Path, "username", username, "client", client, "version", version)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
limiter := newAuthLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var usr *model.User
var err error
username, isInternalAuth := fromInternalOrProxyAuth(r)
if username != "" {
authType := If(isInternalAuth, "internal", "reverse-proxy")
usr, err = ds.User(ctx).FindByUsername(username)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
return
}
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "API: Invalid login", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
} else if err != nil {
log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
}
} else {
p := req.Params(r)
username, _ := p.String("u")
pass, _ := p.String("p")
token, _ := p.String("t")
salt, _ := p.String("s")
jwt, _ := p.String("jwt")
// Blocked attempts get the same response as a wrong password, so they reveal nothing
limitKey := server.ClientIP(r) + "\x00" + strings.ToLower(username)
slot, allowed := limiter.acquire(ctx, limitKey)
if !allowed {
if ctx.Err() != nil {
return
}
log.Warn(ctx, "API: Too many failed login attempts", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr)
sendError(w, r, newError(responses.ErrorAuthenticationFail))
return
}
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
if err == nil {
err = validateCredentials(usr, pass, token, salt, jwt)
}
invalidLogin := errors.Is(err, model.ErrNotFound) || errors.Is(err, model.ErrInvalidAuth)
slot.release(invalidLogin)
switch {
case errors.Is(err, context.Canceled):
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
return
case invalidLogin:
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
case err != nil:
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
}
}
if err != nil {
sendError(w, r, newError(responses.ErrorAuthenticationFail))
return
}
ctx = request.WithUser(ctx, *usr)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func adminOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
loggedUser, ok := request.UserFrom(r.Context())
if !ok {
sendError(w, r, newError(responses.ErrorGeneric, "Internal error"))
return
}
if !loggedUser.IsAdmin {
sendError(w, r, newError(responses.ErrorAuthorizationFail))
return
}
next.ServeHTTP(w, r)
})
}
func validateCredentials(user *model.User, pass, token, salt, jwt string) error {
valid := false
switch {
case jwt != "":
claims, err := auth.Validate(jwt)
valid = err == nil &&
claims.Subject == user.UserName &&
auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil
case pass != "":
if strings.HasPrefix(pass, "enc:") {
if dec, err := hex.DecodeString(pass[4:]); err == nil {
pass = string(dec)
}
}
valid = pass == user.Password
case token != "":
t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt)))
valid = t == token
}
if !valid {
return model.ErrInvalidAuth
}
return nil
}
func getPlayer(players core.Players) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userName, _ := request.UsernameFrom(ctx)
client, _ := request.ClientFrom(ctx)
playerId := playerIDFromCookie(r, userName)
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
userAgent := canonicalUserAgent(r)
player, trc, err := players.Register(ctx, playerId, client, userAgent, ip)
if err != nil {
log.Error(ctx, "Could not register player", "username", userName, "client", client, err)
} else {
ctx = request.WithPlayer(ctx, *player)
if trc != nil {
ctx = request.WithTranscoding(ctx, *trc)
}
r = r.WithContext(ctx)
cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP
Name: playerIDCookieName(userName),
Value: player.ID,
MaxAge: consts.CookieExpiry,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Path: cmp.Or(conf.Server.BasePath, "/"),
}
http.SetCookie(w, cookie)
}
next.ServeHTTP(w, r)
})
}
}
func canonicalUserAgent(r *http.Request) string {
u := ua.Parse(r.Header.Get("user-agent"))
userAgent := u.Name
if u.OS != "" {
userAgent = userAgent + "/" + u.OS
}
return userAgent
}
func playerIDFromCookie(r *http.Request, userName string) string {
cookieName := playerIDCookieName(userName)
var playerId string
if c, err := r.Cookie(cookieName); err == nil {
playerId = c.Value
log.Trace(r, "playerId found in cookies", "playerId", playerId)
}
return playerId
}
func playerIDCookieName(userName string) string {
cookieName := fmt.Sprintf("nd-player-%x", userName)
return cookieName
}
type contextKey string
const subsonicErrorPointer contextKey = "subsonicErrorPointer"
func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
status := int32(-1)
contextWithStatus := context.WithValue(r.Context(), subsonicErrorPointer, &status)
start := time.Now()
defer func() {
elapsed := time.Since(start).Milliseconds()
// We want to get the client name (even if not present for certain endpoints)
p := req.Params(r)
client, _ := p.String("c")
// If there is no Subsonic status (e.g., HTTP 501 not implemented), fallback to HTTP
if status == -1 {
status = int32(ww.Status())
}
shortPath := strings.Replace(r.URL.Path, ".view", "", 1)
metrics.RecordRequest(r.Context(), shortPath, r.Method, client, status, elapsed)
}()
next.ServeHTTP(ww, r.WithContext(contextWithStatus))
}
return http.HandlerFunc(fn)
}
}