navidrome/core/players.go
Deluan Quintão 9e8811e4c5
fix(server): enforce player ownership on create and registration (#6184)
POST /api/player passed the ownership check using the userId from the request body, then
saved with the body id. When that id belonged to another user's player, the save became an
update with no owner restriction, overwriting the row and moving it to the caller. Save now
always creates a new player and ignores any id in the body; edits keep going through the
owner-scoped Update.

Player registration also reused a player by the id sent in the Subsonic player cookie or the
Jellyfin DeviceId without checking its owner, letting a user attach to another user's player
and overwrite its name, user agent and IP. Register now only reuses a player owned by the
requesting user, falling back to the user's own players otherwise.
2026-09-20 21:10:41 -04:00

82 lines
2.4 KiB
Go

package core
import (
"context"
"fmt"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils"
)
type Players interface {
Get(ctx context.Context, playerId string) (*model.Player, error)
Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error)
}
func NewPlayers(ds model.DataStore) Players {
return &players{
ds: ds,
limiter: utils.Limiter{Interval: consts.UpdatePlayerFrequency},
}
}
type players struct {
ds model.DataStore
limiter utils.Limiter
}
func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
var plr *model.Player
var trc *model.Transcoding
var err error
user, _ := request.UserFrom(ctx)
if playerID != "" {
plr, err = p.ds.Player(ctx).Get(playerID)
if err == nil && (plr.Client != client || plr.UserId != user.ID) {
playerID = ""
}
}
username := userName(ctx)
if err != nil || playerID == "" {
plr, err = p.ds.Player(ctx).FindMatch(user.ID, client, userAgent)
if err == nil {
log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", username, "type", userAgent)
} else {
plr = &model.Player{
ID: id.NewRandom(),
UserId: user.ID,
Client: client,
ScrobbleEnabled: true,
ReportRealPath: conf.Server.Subsonic.DefaultReportRealPath,
}
log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", username, "type", userAgent)
}
}
plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
plr.UserAgent = userAgent
plr.IP = ip
plr.LastSeen = time.Now()
p.limiter.Do(plr.ID, func() {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
err = p.ds.Player(ctx).Put(plr)
if err != nil {
log.Warn(ctx, "Could not save player", "id", plr.ID, "client", client, "username", username, "type", userAgent, err)
}
})
if plr.TranscodingId != "" {
trc, err = p.ds.Transcoding(ctx).Get(plr.TranscodingId)
}
return plr, trc, err
}
func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) {
return p.ds.Player(ctx).Get(playerId)
}