From 2731e25fd2fe3bfd9d7c151edd979c3be05fce0c Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:34:26 +0000 Subject: [PATCH] fix(ui): use div for fragment, check lastfm url for artist page (#4980) * fix(ui): use div for fragment, check lastfm url for artist page * use span instead of div for better compat * fix: implement isLastFmURL utility and add tests for URL validation --------- Co-authored-by: Deluan --- ui/src/artist/ArtistExternalLink.jsx | 6 +++--- ui/src/common/SafeHTML.jsx | 4 ++-- ui/src/utils/urls.js | 13 +++++++++++++ ui/src/utils/urls.test.js | 25 +++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 ui/src/utils/urls.test.js diff --git a/ui/src/artist/ArtistExternalLink.jsx b/ui/src/artist/ArtistExternalLink.jsx index 1b6d7456..a83972f1 100644 --- a/ui/src/artist/ArtistExternalLink.jsx +++ b/ui/src/artist/ArtistExternalLink.jsx @@ -4,7 +4,7 @@ import { IconButton, Tooltip, Link } from '@material-ui/core' import { ImLastfm2 } from 'react-icons/im' import MusicBrainz from '../icons/MusicBrainz' -import { intersperse } from '../utils' +import { intersperse, isLastFmURL } from '../utils' import config from '../config' import { makeStyles } from '@material-ui/core/styles' @@ -38,13 +38,13 @@ const ArtistExternalLinks = ({ artistInfo, record }) => { } if (config.lastFMEnabled) { - if (lastFMlink) { + if (lastFMlink && isLastFmURL(lastFMlink[2])) { addLink( lastFMlink[2], 'message.openIn.lastfm', , ) - } else if (artistInfo?.lastFmUrl) { + } else if (isLastFmURL(artistInfo?.lastFmUrl)) { addLink( artistInfo?.lastFmUrl, 'message.openIn.lastfm', diff --git a/ui/src/common/SafeHTML.jsx b/ui/src/common/SafeHTML.jsx index 643b538b..98088356 100644 --- a/ui/src/common/SafeHTML.jsx +++ b/ui/src/common/SafeHTML.jsx @@ -1,5 +1,5 @@ import DOMPurify from 'dompurify' -import { Fragment, useMemo } from 'react' +import { useMemo } from 'react' export const SafeHTML = ({ children }) => { const purified = useMemo(() => { @@ -23,5 +23,5 @@ export const SafeHTML = ({ children }) => { return purify.sanitize(children, { ADD_ATTR: ['referrer-policy'] }) }, [children]) - return + return } diff --git a/ui/src/utils/urls.js b/ui/src/utils/urls.js index 5788096d..80207fe8 100644 --- a/ui/src/utils/urls.js +++ b/ui/src/utils/urls.js @@ -44,3 +44,16 @@ export const shareCoverUrl = (id, square) => { } export const docsUrl = (path) => `https://www.navidrome.org${path}` + +export const isLastFmURL = (url) => { + try { + const parsed = new URL(url) + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + (parsed.hostname === 'last.fm' || parsed.hostname.endsWith('.last.fm')) && + parsed.pathname.startsWith('/music/') + ) + } catch (e) { + return false + } +} diff --git a/ui/src/utils/urls.test.js b/ui/src/utils/urls.test.js new file mode 100644 index 00000000..26bdd128 --- /dev/null +++ b/ui/src/utils/urls.test.js @@ -0,0 +1,25 @@ +import { isLastFmURL } from './urls' + +describe('isLastFmURL', () => { + it('returns true for valid Last.fm music URLs', () => { + expect(isLastFmURL('https://last.fm/music/The+Beatles')).toBe(true) + expect(isLastFmURL('http://last.fm/music/Radiohead')).toBe(true) + expect(isLastFmURL('https://www.last.fm/music/Daft+Punk')).toBe(true) + }) + + it('returns false for non-http(s) protocols (XSS prevention)', () => { + expect(isLastFmURL('javascript:alert(1)//last.fm/music/')).toBe(false) + expect(isLastFmURL('data:text/html,