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
249 lines
8.2 KiB
Go
249 lines
8.2 KiB
Go
package public
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"time"
|
|
|
|
"github.com/go-chi/jwtauth/v5"
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
type mockStreamer struct {
|
|
req stream.Request
|
|
called bool
|
|
}
|
|
|
|
func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream.Request) (*stream.Stream, error) {
|
|
m.called = true
|
|
m.req = r
|
|
return nil, errors.New("mock: not implemented")
|
|
}
|
|
|
|
var _ = Describe("decodeStreamInfo", func() {
|
|
BeforeEach(func() {
|
|
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
})
|
|
|
|
It("decodes a valid token with all fields", func() {
|
|
claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
info, err := decodeStreamInfo(token)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(info.id).To(Equal("mf-123"))
|
|
Expect(info.format).To(Equal("mp3"))
|
|
Expect(info.bitrate).To(Equal(192))
|
|
Expect(info.shareID).To(Equal("share123"))
|
|
})
|
|
|
|
It("rejects an expired token", func() {
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims)
|
|
_, err := decodeStreamInfo(token)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("accepts a token without exp (non-expiring share)", func() {
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
info, err := decodeStreamInfo(token)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(info.id).To(Equal("mf-123"))
|
|
Expect(info.shareID).To(Equal("share123"))
|
|
})
|
|
|
|
It("rejects a token without an id claim", func() {
|
|
claims := auth.Claims{ShareID: "share123"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
_, err := decodeStreamInfo(token)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("rejects an invalid token string", func() {
|
|
_, err := decodeStreamInfo("not-a-valid-token")
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("rejects a token without a shareID claim", func() {
|
|
claims := auth.Claims{ID: "mf-123", Format: "opus"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
_, err := decodeStreamInfo(token)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("encodeMediafileShare", func() {
|
|
BeforeEach(func() {
|
|
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
})
|
|
|
|
It("includes the share ID in the token", func() {
|
|
exp := new(time.Now().Add(time.Hour))
|
|
s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp}
|
|
token := encodeMediafileShare(s, "mf-999")
|
|
info, err := decodeStreamInfo(token)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(info.shareID).To(Equal("shareABC"))
|
|
Expect(info.id).To(Equal("mf-999"))
|
|
Expect(info.format).To(Equal("mp3"))
|
|
Expect(info.bitrate).To(Equal(320))
|
|
})
|
|
|
|
It("creates a non-expiring token when share has no expiry", func() {
|
|
s := model.Share{ID: "shareXYZ", ExpiresAt: nil}
|
|
token := encodeMediafileShare(s, "mf-111")
|
|
info, err := decodeStreamInfo(token)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(info.shareID).To(Equal("shareXYZ"))
|
|
Expect(info.id).To(Equal("mf-111"))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("handleStream", func() {
|
|
var ds *tests.MockDataStore
|
|
var shareRepo *tests.MockShareRepo
|
|
var streamer *mockStreamer
|
|
var pub *Router
|
|
|
|
BeforeEach(func() {
|
|
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
ds = &tests.MockDataStore{}
|
|
shareRepo = &tests.MockShareRepo{}
|
|
ds.MockedShare = shareRepo
|
|
streamer = &mockStreamer{}
|
|
pub = &Router{ds: ds, streamer: streamer}
|
|
})
|
|
|
|
makeRequest := func(token string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest("GET", "/public/s/token?%3Aid="+token, nil)
|
|
w := httptest.NewRecorder()
|
|
pub.handleStream(w, r)
|
|
return w
|
|
}
|
|
|
|
shareOwnedBy := func(owner model.User, mf model.MediaFile) {
|
|
shareRepo.ID = "share123"
|
|
shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{mf}}
|
|
userRepo := tests.CreateMockUserRepo()
|
|
Expect(userRepo.Put(&owner)).To(Succeed())
|
|
ds.MockedUser = userRepo
|
|
mfRepo := tests.CreateMockMediaFileRepo()
|
|
mfRepo.SetData(model.MediaFiles{mf})
|
|
ds.MockedMediaFile = mfRepo
|
|
}
|
|
|
|
It("passes all validation and reaches the streamer for a valid token", func() {
|
|
shareOwnedBy(
|
|
model.User{ID: "owner1", UserName: "owner1", IsAdmin: true},
|
|
model.MediaFile{ID: "mf-123", Title: "Test Song"},
|
|
)
|
|
|
|
claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
makeRequest(token)
|
|
|
|
Expect(streamer.called).To(BeTrue())
|
|
Expect(streamer.req.Format).To(Equal("mp3"))
|
|
Expect(streamer.req.BitRate).To(Equal(192))
|
|
})
|
|
|
|
It("returns 404 when the track is outside the share owner's libraries", func() {
|
|
shareOwnedBy(
|
|
model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}},
|
|
model.MediaFile{ID: "mf-restricted", Title: "Other Lib Track", LibraryID: 2},
|
|
)
|
|
|
|
claims := auth.Claims{ID: "mf-restricted", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
w := makeRequest(token)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(streamer.called).To(BeFalse())
|
|
})
|
|
|
|
It("returns 404 when the track is not a member of the share", func() {
|
|
owner := model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}
|
|
userRepo := tests.CreateMockUserRepo()
|
|
Expect(userRepo.Put(&owner)).To(Succeed())
|
|
ds.MockedUser = userRepo
|
|
mfRepo := tests.CreateMockMediaFileRepo()
|
|
mfRepo.SetData(model.MediaFiles{{ID: "mf-shared"}, {ID: "mf-other"}})
|
|
ds.MockedMediaFile = mfRepo
|
|
shareRepo.ID = "share123"
|
|
shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{{ID: "mf-shared"}}}
|
|
|
|
claims := auth.Claims{ID: "mf-other", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
w := makeRequest(token)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(streamer.called).To(BeFalse())
|
|
})
|
|
|
|
It("streams a track inside the share owner's libraries", func() {
|
|
shareOwnedBy(
|
|
model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}},
|
|
model.MediaFile{ID: "mf-ok", Title: "OK", LibraryID: 1},
|
|
)
|
|
|
|
claims := auth.Claims{ID: "mf-ok", Format: "mp3", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
makeRequest(token)
|
|
|
|
Expect(streamer.called).To(BeTrue())
|
|
})
|
|
|
|
It("returns 400 for an expired token", func() {
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 404 when share has been deleted", func() {
|
|
shareRepo.ID = "other-share"
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "deleted-share"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 410 when share has been set to expired", func() {
|
|
shareRepo.ID = "share123"
|
|
shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: new(time.Now().Add(-time.Hour))}
|
|
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusGone))
|
|
})
|
|
|
|
It("returns 500 when share lookup fails", func() {
|
|
shareRepo.Error = errors.New("db error")
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
|
|
It("returns 400 for tokens without a shareID", func() {
|
|
claims := auth.Claims{ID: "mf-123"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
Expect(streamer.called).To(BeFalse())
|
|
})
|
|
|
|
It("returns 400 for an invalid token", func() {
|
|
w := makeRequest("not-a-valid-token")
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
})
|