Merge commit from fork

This commit is contained in:
Sudo-Ivan 2026-09-12 12:37:33 -05:00 • committed by GitHub
commit 4168377b65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 64 additions and 3 deletions

View file

@ -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')"

View file

@ -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.