mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-09 10:57:08 +02:00
* 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.
134 lines
2.8 KiB
Go
134 lines
2.8 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"hash/maphash"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/consts"
|
|
)
|
|
|
|
// authLimiter caps failed Subsonic logins per key. Checks run at most `limit` at a time and failures
|
|
// are recorded afterwards, so a window admits up to 2*limit-1 guesses and valid requests only wait.
|
|
type authLimiter struct {
|
|
limit int
|
|
window time.Duration
|
|
seed maphash.Seed
|
|
mu sync.Mutex
|
|
keys map[uint64]*authAttempts // hashed, so attacker-chosen usernames cannot bloat memory
|
|
lastSweep time.Time
|
|
}
|
|
|
|
type authAttempts struct {
|
|
failures int
|
|
start time.Time
|
|
slots chan struct{}
|
|
refs int
|
|
}
|
|
|
|
// authSlot is a reserved credential check. A nil slot releases nothing, which is what a disabled
|
|
// limiter hands back.
|
|
type authSlot struct {
|
|
limiter *authLimiter
|
|
entry *authAttempts
|
|
}
|
|
|
|
// newAuthLimiter returns nil when limit is not positive. A nil limiter allows everything.
|
|
func newAuthLimiter(limit int, window time.Duration) *authLimiter {
|
|
if limit <= 0 {
|
|
return nil
|
|
}
|
|
return &authLimiter{
|
|
limit: limit,
|
|
window: cmp.Or(window, consts.DefaultAuthWindowLength),
|
|
seed: maphash.MakeSeed(),
|
|
keys: map[uint64]*authAttempts{},
|
|
}
|
|
}
|
|
|
|
// acquire reserves a credential check for key, waiting while other checks for the same key are in
|
|
// flight. It only fails when the key already reached `limit` failures in the current window.
|
|
func (l *authLimiter) acquire(ctx context.Context, key string) (*authSlot, bool) {
|
|
if l == nil {
|
|
return nil, true
|
|
}
|
|
a, ok := l.reserve(key)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
select {
|
|
case a.slots <- struct{}{}:
|
|
case <-ctx.Done():
|
|
l.unref(a)
|
|
return nil, false
|
|
}
|
|
|
|
l.mu.Lock()
|
|
blocked := a.failures >= l.limit
|
|
if blocked {
|
|
a.refs--
|
|
}
|
|
l.mu.Unlock()
|
|
if blocked {
|
|
<-a.slots
|
|
return nil, false
|
|
}
|
|
return &authSlot{limiter: l, entry: a}, true
|
|
}
|
|
|
|
func (l *authLimiter) reserve(key string) (*authAttempts, bool) {
|
|
now := time.Now()
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.sweep(now)
|
|
|
|
h := maphash.String(l.seed, key)
|
|
a := l.keys[h]
|
|
switch {
|
|
case a == nil:
|
|
a = &authAttempts{start: now, slots: make(chan struct{}, l.limit)}
|
|
l.keys[h] = a
|
|
case now.Sub(a.start) >= l.window:
|
|
a.failures, a.start = 0, now
|
|
}
|
|
if a.failures >= l.limit {
|
|
return nil, false
|
|
}
|
|
a.refs++
|
|
return a, true
|
|
}
|
|
|
|
func (l *authLimiter) unref(a *authAttempts) {
|
|
l.mu.Lock()
|
|
a.refs--
|
|
l.mu.Unlock()
|
|
}
|
|
|
|
func (s *authSlot) release(failed bool) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.limiter.mu.Lock()
|
|
if failed {
|
|
s.entry.failures++
|
|
}
|
|
s.entry.refs--
|
|
s.limiter.mu.Unlock()
|
|
<-s.entry.slots
|
|
}
|
|
|
|
// sweep drops idle expired keys once per window, so memory is bounded by recent attempts.
|
|
func (l *authLimiter) sweep(now time.Time) {
|
|
if now.Sub(l.lastSweep) < l.window {
|
|
return
|
|
}
|
|
for h, a := range l.keys {
|
|
if a.refs == 0 && now.Sub(a.start) >= l.window {
|
|
delete(l.keys, h)
|
|
}
|
|
}
|
|
l.lastSweep = now
|
|
}
|