From 4168377b656f1237da4bcb503a08094cb885b115 Mon Sep 17 00:00:00 2001 From: Sudo-Ivan Date: Sat, 12 Sep 2026 12:37:33 -0500 Subject: [PATCH] Merge commit from fork --- persistence/artist_repository.go | 21 ++++++++++-- persistence/artist_repository_test.go | 46 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 1ff291a0f..97c1452cb 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -162,13 +162,26 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi func roleFilter(_ string, role any) Sqlizer { if role, ok := role.(string); ok { - if _, ok := model.AllRoles[role]; ok { - return Expr("JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL") + if safe, ok := sanitizeArtistStatsRole(role); ok && safe != "total" { + return Expr("JSON_EXTRACT(library_artist.stats, '$." + safe + ".m') IS NOT NULL") } } return Eq{"1": 2} } +// sanitizeArtistStatsRole allowlists values interpolated into JSON paths for artist +// stats (filter and sort). "total" is the aggregate key stored by the scanner. +// Unknown values must not reach SQL string concatenation. +func sanitizeArtistStatsRole(role string) (string, bool) { + if role == "" || role == "total" { + return "total", true + } + if _, ok := model.AllRoles[role]; ok { + return role, true + } + return "", false +} + // artistLibraryIdFilter filters artists based on library access through the library_artist table func artistLibraryIdFilter(_ string, value any) Sqlizer { return Eq{"library_artist.library_id": value} @@ -716,7 +729,9 @@ func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (any, error) { role := "total" if len(options) > 0 { if v, ok := options[0].Filters["role"].(string); ok { - role = v + if safe, ok := sanitizeArtistStatsRole(v); ok { + role = safe + } } } r.sortMappings["song_count"] = "sum(stats->>'" + role + "'->>'m')" diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index d337b4c22..f6612acc4 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -112,6 +112,52 @@ var _ = Describe("ArtistRepository", func() { }) }) + Describe("sanitizeArtistStatsRole", func() { + It("allowlists total and registered roles", func() { + role, ok := sanitizeArtistStatsRole("total") + Expect(ok).To(BeTrue()) + Expect(role).To(Equal("total")) + role, ok = sanitizeArtistStatsRole("albumartist") + Expect(ok).To(BeTrue()) + Expect(role).To(Equal("albumartist")) + }) + + It("rejects SQL injection payloads used in sort mappings", func() { + payload := "total'||(SELECT password FROM user LIMIT 1)||'" + role, ok := sanitizeArtistStatsRole(payload) + Expect(ok).To(BeFalse()) + Expect(role).To(BeEmpty()) + }) + }) + + Describe("ReadAll role sort SQL injection", func() { + It("does not interpolate attacker-controlled role into ORDER BY", func() { + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + payload := "total') OR 1=1--" + _, err := repo.ReadAll(rest.QueryOptions{ + Sort: "songCount", + Order: "ASC", + Filters: map[string]any{"role": payload}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(repo.sortMappings["song_count"]).To(Equal("sum(stats->>'total'->>'m')")) + Expect(repo.sortMappings["song_count"]).ToNot(ContainSubstring(payload)) + }) + + It("keeps valid role sort paths", func() { + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + _, err := repo.ReadAll(rest.QueryOptions{ + Sort: "songCount", + Order: "DESC", + Filters: map[string]any{"role": "composer"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(repo.sortMappings["song_count"]).To(Equal("sum(stats->>'composer'->>'m')")) + }) + }) + Describe("searchScope", func() { // Resolves the library IDs a search must be restricted to (nil = fast-path / no filter), // the way Search() does, for a repo whose context carries the given user.