mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-28 03:19:38 +00:00
fix(share): add ownership checks to Delete and Update (#5189)
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
* test(share): add failing tests for Delete ownership checks * fix(share): add ownership check to Delete * test(share): add failing tests for Update ownership checks * fix(share): add ownership check to Update * refactor(share): extract checkOwnership helper with lightweight query - Deduplicate ownership check from Delete and Update into a single helper - Use a minimal single-column SELECT instead of Get (avoids loadMedia overhead) - Use positive bypass form (IsAdmin || invalidUserId) matching codebase convention * fix(share): convert model.ErrNotFound to rest.ErrNotFound in checkOwnership Ensure consistent 404 responses when a nonexistent share ID is passed to Delete or Update, by handling the conversion in checkOwnership rather than relying on the subsequent write operation.
This commit is contained in:
parent
197d357f02
commit
6b8fcc37c6
2 changed files with 117 additions and 1 deletions
|
|
@ -30,7 +30,33 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito
|
|||
return r
|
||||
}
|
||||
|
||||
// TODO: Ownership checks should be moved to the service layer (core/share.go)
|
||||
func (r *shareRepository) checkOwnership(id string) error {
|
||||
usr := loggedUser(r.ctx)
|
||||
if usr.IsAdmin || usr.ID == invalidUserId {
|
||||
return nil
|
||||
}
|
||||
sel := r.newSelect().Columns("user_id").Where(Eq{"id": id})
|
||||
var share struct {
|
||||
UserID string `db:"user_id"`
|
||||
}
|
||||
err := r.queryOne(sel, &share)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if share.UserID != usr.ID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *shareRepository) Delete(id string) error {
|
||||
if err := r.checkOwnership(id); err != nil {
|
||||
return err
|
||||
}
|
||||
err := r.delete(Eq{"id": id})
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
|
|
@ -140,7 +166,9 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles {
|
|||
|
||||
func (r *shareRepository) Update(id string, entity any, cols ...string) error {
|
||||
s := entity.(*model.Share)
|
||||
// TODO Validate record
|
||||
if err := r.checkOwnership(id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.ID = id
|
||||
s.UpdatedAt = time.Now()
|
||||
cols = append(cols, "updated_at")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -130,4 +131,91 @@ var _ = Describe("ShareRepository", func() {
|
|||
Expect(share.Albums).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Ownership Checks", func() {
|
||||
var ownerUser = model.User{ID: "2222", UserName: "regular-user"}
|
||||
var otherUser = model.User{ID: "3333", UserName: "third-user"}
|
||||
|
||||
insertShare := func(shareID, userID string) {
|
||||
_, err := GetDBXBuilder().NewQuery(`
|
||||
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
|
||||
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
|
||||
`).Bind(map[string]any{
|
||||
"id": shareID,
|
||||
"user": userID,
|
||||
"desc": "Test Share",
|
||||
"type": "media_file",
|
||||
"ids": "1001",
|
||||
"created": time.Now(),
|
||||
"updated": time.Now(),
|
||||
}).Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("allows a non-admin user to delete their own share", func() {
|
||||
insertShare("own-share-del", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Delete("own-share-del")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies a non-admin user from deleting another user's share", func() {
|
||||
insertShare("other-share-del", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), otherUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Delete("other-share-del")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("allows an admin to delete any user's share", func() {
|
||||
insertShare("admin-del-share", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Delete("admin-del-share")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows headless context (no user) to delete a share", func() {
|
||||
insertShare("headless-del-share", ownerUser.ID)
|
||||
repo := NewShareRepository(context.Background(), GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Delete("headless-del-share")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
It("allows a non-admin user to update their own share", func() {
|
||||
insertShare("own-share-upd", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("own-share-upd", &model.Share{Description: "Updated"}, "description")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies a non-admin user from updating another user's share", func() {
|
||||
insertShare("other-share-upd", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), otherUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("other-share-upd", &model.Share{Description: "Hacked"}, "description")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("allows an admin to update any user's share", func() {
|
||||
insertShare("admin-upd-share", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("admin-upd-share", &model.Share{Description: "Admin Updated"}, "description")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows headless context (no user) to update a share", func() {
|
||||
insertShare("headless-upd-share", ownerUser.ID)
|
||||
repo := NewShareRepository(context.Background(), GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue