diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index e309ece6e..cc9506d1f 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -105,6 +105,29 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ag.AssertExpectations(GinkgoT()) }) + It("preserves decoded plain text in biography storage", func() { + originalArtist := &model.Artist{ + ID: "ar-encoded-bio", + Name: "Encoded Bio Artist", + } + mockArtistRepo.SetData(model.Artists{*originalArtist}) + + expectedMBID := "mbid-encoded-bio" + expectedBio := "R&B" + + ag.On("GetArtistMBID", ctx, "ar-encoded-bio", "Encoded Bio Artist").Return(expectedMBID, nil).Once() + ag.On("GetArtistImages", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(nil, nil).Maybe() + ag.On("GetArtistBiography", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(expectedBio, nil).Once() + ag.On("GetArtistURL", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return("", nil).Maybe() + ag.On("GetSimilarArtists", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID, 100).Return(nil, nil).Maybe() + + updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-encoded-bio", 10, false) + + Expect(err).NotTo(HaveOccurred()) + Expect(updatedArtist).NotTo(BeNil()) + Expect(updatedArtist.Biography).To(Equal("R&B")) + }) + It("returns cached info when artist exists and info is not expired", func() { now := time.Now() originalArtist := &model.Artist{ diff --git a/server/serve_index.go b/server/serve_index.go index bd5be44f5..734aabc70 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -45,7 +45,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "variousArtistsId": consts.VariousArtistsID, "baseURL": str.SanitizeText(strings.TrimSuffix(conf.Server.BasePath, "/")), "loginBackgroundURL": str.SanitizeText(conf.Server.UILoginBackgroundURL), - "welcomeMessage": str.SanitizeText(conf.Server.UIWelcomeMessage), + "welcomeMessage": str.SanitizeHTML(conf.Server.UIWelcomeMessage), "maxSidebarPlaylists": conf.Server.MaxSidebarPlaylists, "enableTranscodingConfig": conf.Server.EnableTranscodingConfig, "enableDownloads": conf.Server.EnableDownloads, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 7515e7276..31bca02cf 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -108,6 +108,18 @@ var _ = Describe("serveIndex", func() { Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"), ) + It("sanitizes entity-encoded welcomeMessage as html", func() { + conf.Server.UIWelcomeMessage = `<img src=x onerror=alert(1)><b>Hello</b>` + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKey("welcomeMessage")) + Expect(config["welcomeMessage"]).To(Equal(`Hello`)) + }) + DescribeTable("sets other UI configuration values", func(configKey string, expectedValueFunc func() any) { r := httptest.NewRequest("GET", "/index.html", nil) diff --git a/utils/str/sanitize_strings.go b/utils/str/sanitize_strings.go index 73608112e..c121aefe7 100644 --- a/utils/str/sanitize_strings.go +++ b/utils/str/sanitize_strings.go @@ -38,11 +38,20 @@ func SanitizeStrings(text ...string) string { var policy = bluemonday.UGCPolicy() +// SanitizeText unescapes the input string before sanitizing it as text. +// This should be used for fields rendered as plain text in the UI (e.g. lyrics, song titles, artist names) func SanitizeText(text string) string { s := policy.Sanitize(text) return html.UnescapeString(s) } +// SanitizeHTML unescapes the input string before sanitizing it as HTML. +// This should be used for fields rendered as HTML by clients (e.g. biographies, welcome messages) +// to prevent XSS bypasses via entity-encoded tags. +func SanitizeHTML(text string) string { + return policy.Sanitize(html.UnescapeString(text)) +} + func SanitizeFieldForSorting(originalValue string) string { v := strings.TrimSpace(sanitize.Accents(originalValue)) return Clear(strings.ToLower(v)) diff --git a/utils/str/sanitize_strings_test.go b/utils/str/sanitize_strings_test.go index ac28fe435..6527f326b 100644 --- a/utils/str/sanitize_strings_test.go +++ b/utils/str/sanitize_strings_test.go @@ -64,6 +64,35 @@ var _ = Describe("Sanitize Strings", func() { }) }) + Describe("SanitizeText", func() { + It("preserves decoded plaintext", func() { + Expect(str.SanitizeText("Tom & Jerry")).To(Equal("Tom & Jerry")) + Expect(str.SanitizeText("Tom & Jerry")).To(Equal("Tom & Jerry")) + }) + + It("keeps entity-encoded html readable", func() { + Expect(str.SanitizeText(`<b>ok</b>`)).To(Equal("ok")) + }) + }) + + Describe("SanitizeHTML", func() { + It("removes dangerous content from raw html", func() { + sanitized := str.SanitizeHTML(`ok`) + + Expect(sanitized).To(ContainSubstring("ok")) + Expect(sanitized).ToNot(ContainSubstring("onerror")) + Expect(sanitized).ToNot(ContainSubstring("ok")) + Expect(sanitized).ToNot(ContainSubstring("onerror")) + Expect(sanitized).ToNot(ContainSubstring("