mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* fix(share): enforce track membership on public share streams
The public share stream endpoint (GET /share/s/{jwt}) validated that the
share existed, was unexpired, and that the share owner had library access
to the requested track, but it never verified that the track was actually
a member of the share. It also accepted stream tokens with no share id
(sid) claim, skipping share checks entirely.
Enforce that the requested media file belongs to share.Tracks, and make
the sid claim mandatory on the stream path. The only producer of stream
tokens (encodeMediafileShare) always sets sid, so no legitimate flow is
affected; the image endpoint decodes independently and is unchanged.
Also document why a JWT is used to represent a shared track: it is a
signed, scoped capability for a single public share, not part of
authentication.
* docs(share): clarify JWT usage comment wording
127 lines
3.8 KiB
Go
127 lines
3.8 KiB
Go
package public
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
streampkg "github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
. "github.com/navidrome/navidrome/utils/gg"
|
|
"github.com/navidrome/navidrome/utils/req"
|
|
)
|
|
|
|
func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
tokenId, _ := p.String(":id")
|
|
info, err := decodeStreamInfo(tokenId)
|
|
if err != nil {
|
|
log.Error(ctx, "Error parsing shared stream info", err)
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
share, err := pub.ds.Share(ctx).Get(info.shareID)
|
|
if err != nil {
|
|
checkShareError(ctx, w, err, info.shareID)
|
|
return
|
|
}
|
|
if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
|
|
checkShareError(ctx, w, model.ErrExpired, info.shareID)
|
|
return
|
|
}
|
|
shareOwner, err := pub.ds.User(ctx).Get(share.UserID)
|
|
if err != nil {
|
|
log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
mf, err := pub.ds.MediaFile(ctx).Get(info.id)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
} else {
|
|
log.Error(ctx, "Error retrieving media file for shared stream", "id", info.id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
// 404 rather than 403 so the response doesn't reveal whether the id exists.
|
|
// The track must belong to the share AND be within the owner's libraries.
|
|
if !shareContainsTrack(share, mf.ID) || !shareOwner.HasLibraryAccess(mf.LibraryID) {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{
|
|
Format: info.format, BitRate: info.bitrate,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, streampkg.ErrTooManyTranscodes) {
|
|
w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds))
|
|
http.Error(w, "too many concurrent transcodes, please retry shortly", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
log.Error(ctx, "Error starting shared stream", err)
|
|
http.Error(w, "invalid request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Make sure the stream will be closed at the end, to avoid leakage
|
|
defer func() {
|
|
if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
|
log.Error("Error closing shared stream", "id", info.id, "file", stream.Name(), err)
|
|
}
|
|
}()
|
|
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
|
|
|
|
n, err := stream.Serve(ctx, w, r)
|
|
if err != nil || n == 0 {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type shareTrackInfo struct {
|
|
id string
|
|
format string
|
|
bitrate int
|
|
shareID string
|
|
}
|
|
|
|
func shareContainsTrack(share *model.Share, mediaFileID string) bool {
|
|
return slices.ContainsFunc(share.Tracks, func(mf model.MediaFile) bool {
|
|
return mf.ID == mediaFileID
|
|
})
|
|
}
|
|
|
|
// decodeStreamInfo decodes the signed share-link token. This is a scoped
|
|
// public-share capability, not an auth credential; see encodeMediafileShare for
|
|
// why a JWT is used here.
|
|
func decodeStreamInfo(tokenString string) (shareTrackInfo, error) {
|
|
c, err := auth.Validate(tokenString)
|
|
if err != nil {
|
|
return shareTrackInfo{}, err
|
|
}
|
|
if c.ID == "" {
|
|
return shareTrackInfo{}, errors.New("required claim \"id\" not found")
|
|
}
|
|
if c.ShareID == "" {
|
|
return shareTrackInfo{}, errors.New("required claim \"sid\" not found")
|
|
}
|
|
return shareTrackInfo{
|
|
id: c.ID,
|
|
format: c.Format,
|
|
bitrate: c.BitRate,
|
|
shareID: c.ShareID,
|
|
}, nil
|
|
}
|