navidrome/server/public/handle_shares.go
Deluan Quintão 969e7e108c
fix(share): enforce track membership on public share streams (#5769)
* 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
2026-07-13 09:04:24 -04:00

126 lines
4.1 KiB
Go

package public
import (
"context"
"errors"
"net/http"
"path"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/publicurl"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/ui"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/req"
)
func (pub *Router) handleShares(w http.ResponseWriter, r *http.Request) {
id, err := req.Params(r).String(":id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If requested file is a UI asset, just serve it
_, err = ui.BuildAssets().Open(id)
if err == nil {
pub.assetsHandler.ServeHTTP(w, r)
return
}
// If it is not, consider it a share ID
s, err := pub.share.Load(r.Context(), id)
if err != nil {
checkShareError(r.Context(), w, err, id)
return
}
s = pub.mapShareInfo(r, *s)
server.IndexWithShare(pub.ds, ui.BuildAssets(), s)(w, r)
}
func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) {
id, err := req.Params(r).String(":id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If it is not, consider it a share ID
s, err := pub.share.Load(r.Context(), id)
if err != nil {
checkShareError(r.Context(), w, err, id)
return
}
s = pub.mapShareToM3U(r, *s)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "audio/x-mpegurl")
_, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec
}
func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) {
switch {
case errors.Is(err, model.ErrExpired):
log.Error(ctx, "Share expired", "id", id, err)
http.Error(w, "Share not available anymore", http.StatusGone)
case errors.Is(err, model.ErrNotFound):
log.Error(ctx, "Share not found", "id", id, err)
http.Error(w, "Share not found", http.StatusNotFound)
case errors.Is(err, model.ErrNotAuthorized):
log.Error(ctx, "Share is not downloadable", "id", id, err)
http.Error(w, "This share is not downloadable", http.StatusForbidden)
case err != nil:
log.Error(ctx, "Error retrieving share", "id", id, err)
http.Error(w, "Error retrieving share", http.StatusInternalServerError)
}
}
func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share {
s.URL = ShareURL(r, s.ID)
s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), conf.Server.UICoverArtSize)
for i := range s.Tracks {
s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID)
}
return &s
}
func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share {
for i := range s.Tracks {
id := encodeMediafileShare(s, s.Tracks[i].ID)
s.Tracks[i].Path = publicurl.PublicURL(r, path.Join(consts.URLPathPublic, "s", id), nil)
}
return &s
}
// encodeMediafileShare builds the signed token embedded in a public share link
// for a single track.
//
// NOTE ON JWT USAGE: This is deliberately NOT part of Navidrome's authentication.
// The token is a signed, opaque capability that identifies one shared track
// (plus its transcode format/bitrate and the parent share id). We use a JWT here
// (reusing the library we already have) because it is a simple way to get three
// properties for a public link: the embedded ids can't be enumerated by guessing,
// the signature
// makes the claims tamper-evident, and the self-contained exp lets us reject
// stale links without a DB lookup. It carries no user identity (no subject, no
// admin flag) and grants access to nothing beyond the share it belongs to; the
// stream handler still verifies the share exists, is unexpired, and that the
// track is actually a member of it. An attacker who can forge these tokens
// necessarily already holds the signing secret, which also signs real user
// sessions, so that scenario is out of scope for the share boundary specifically.
func encodeMediafileShare(s model.Share, id string) string {
claims := auth.Claims{
ID: id,
Format: s.Format,
BitRate: s.MaxBitRate,
ShareID: s.ID,
}
token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims)
return token
}