Merge commit from fork

* Rework frontend code interacting directly with DOM

Rework frontend code that uses user-supplied data to render things like
comments and notes. In places where using React's built-in sanitization
is possible, the feature is used. In other places, where some markup
might be necessary, DOMPurify is used to sanitize the HTML before
rendering it.

Solves: GHSA-rh3r-8pxm-hg4w

* Remove test post DOM rework

* fixup! Rework frontend code interacting directly with DOM
This commit is contained in:
Alex Gustafsson
2026-02-03 18:22:57 +01:00
committed by GitHub
parent c3a4585c83
commit d7ec7355c9
11 changed files with 99 additions and 69 deletions
+4 -4
View File
@@ -33,6 +33,7 @@ import {
import config from '../config'
import { formatFullDate, intersperse } from '../utils'
import AlbumExternalLinks from './AlbumExternalLinks'
import { SafeHTML } from '../common/SafeHTML'
const useStyles = makeStyles(
(theme) => ({
@@ -225,8 +226,7 @@ const AlbumDetails = (props) => {
const [imageLoading, setImageLoading] = useState(false)
const [imageError, setImageError] = useState(false)
let notes =
albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes
let notes = albumInfo?.notes || record.notes
if (notes) {
notes += '..'
@@ -351,7 +351,7 @@ const AlbumDetails = (props) => {
variant={'body1'}
onClick={() => setExpanded(!expanded)}
>
<span dangerouslySetInnerHTML={{ __html: notes }} />
<span><SafeHTML>{notes}</SafeHTML></span>
</Typography>
</Collapse>
)}
@@ -371,7 +371,7 @@ const AlbumDetails = (props) => {
variant={'body1'}
onClick={() => setExpanded(!expanded)}
>
<span dangerouslySetInnerHTML={{ __html: notes }} />
<span><SafeHTML>{notes}</SafeHTML></span>
</Typography>
</Collapse>
</div>
+4 -14
View File
@@ -1,4 +1,4 @@
import React, { useState, createElement, useEffect } from 'react'
import { useState, useEffect } from 'react'
import { useMediaQuery, withWidth } from '@material-ui/core'
import {
useShowController,
@@ -53,9 +53,7 @@ const ArtistDetails = (props) => {
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm'))
const [artistInfo, setArtistInfo] = useState()
const biography =
artistInfo?.biography?.replace(new RegExp('<.*>', 'g'), '') ||
record.biography
const biography = artistInfo?.biography || record.biography
useEffect(() => {
subsonic
@@ -72,16 +70,8 @@ const ArtistDetails = (props) => {
})
}, [record.id])
const component = isDesktop ? DesktopArtistDetails : MobileArtistDetails
return (
<>
{createElement(component, {
artistInfo,
record,
biography,
})}
</>
)
const Component = isDesktop ? DesktopArtistDetails : MobileArtistDetails
return <Component artistInfo={artistInfo} record={record} biography={biography} />
}
const ArtistShowLayout = (props) => {
+2 -1
View File
@@ -11,6 +11,7 @@ import Lightbox from 'react-image-lightbox'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import AlbumInfo from '../album/AlbumInfo'
import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML'
const useStyles = makeStyles(
(theme) => ({
@@ -172,7 +173,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
variant={'body1'}
onClick={() => setExpanded(!expanded)}
>
<span dangerouslySetInnerHTML={{ __html: biography }} />
<span><SafeHTML>{biography}</SafeHTML></span>
</Typography>
</Collapse>
</CardContent>
+2 -1
View File
@@ -7,6 +7,7 @@ import config from '../config'
import { LoveButton, RatingField } from '../common'
import Lightbox from 'react-image-lightbox'
import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML'
const useStyles = makeStyles(
(theme) => ({
@@ -168,7 +169,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
<div className={classes.biography}>
<Collapse collapsedHeight={'1.5em'} in={expanded} timeout={'auto'}>
<Typography variant={'body1'} onClick={() => setExpanded(!expanded)}>
<span dangerouslySetInnerHTML={{ __html: biography }} />
<span><SafeHTML>{biography}</SafeHTML></span>
</Typography>
</Collapse>
</div>
+1 -6
View File
@@ -53,12 +53,7 @@ const Linkify = ({ text, ...rest }) => {
// Push remaining text
if (text.length > lastIndex) {
elements.push(
<span
key={'last-span-key'}
dangerouslySetInnerHTML={{ __html: text.substring(lastIndex) }}
/>,
)
elements.push(text.substring(lastIndex))
}
return elements.length === 1 ? elements[0] : elements
+1 -11
View File
@@ -30,17 +30,7 @@ export const MultiLineTextField = memo(
>
{lines.length === 0 && emptyText
? emptyText
: lines.map((line, idx) =>
line === '' ? (
<br key={md5(line + idx)} />
) : (
<div
data-testid={`${source}.${idx}`}
key={md5(line + idx)}
dangerouslySetInnerHTML={{ __html: line }}
/>
),
)}
: lines}
</Typography>
)
},
-28
View File
@@ -1,28 +0,0 @@
import * as React from 'react'
import { render, cleanup, screen } from '@testing-library/react'
import { MultiLineTextField } from './MultiLineTextField'
describe('<MultiLineTextField />', () => {
afterEach(cleanup)
it('should render each line in a separated div', () => {
const record = { comment: 'line1\nline2' }
render(<MultiLineTextField record={record} source={'comment'} />)
expect(screen.queryByTestId('comment.0').textContent).toBe('line1')
expect(screen.queryByTestId('comment.1').textContent).toBe('line2')
})
it.each([null, undefined])(
'should render the emptyText when value is %s',
(body) => {
render(
<MultiLineTextField
record={{ id: 123, body }}
emptyText="NA"
source="body"
/>,
)
expect(screen.getByText('NA')).toBeInTheDocument()
},
)
})
+29
View File
@@ -0,0 +1,29 @@
import DOMPurify from 'dompurify'
import { Fragment, useMemo } from 'react'
export const SafeHTML = ({
children,
}) => {
const purified = useMemo(() => {
const purify = DOMPurify()
purify.addHook('afterSanitizeElements', async (node) => {
if (node instanceof HTMLElement) {
// Set referrer-policy for elements with src
switch (node.tagName.toLowerCase()) {
case 'a':
case 'area':
case 'img':
case 'video':
case 'iframe':
case 'script':
node.setAttribute('referrer-policy', 'no-referrer')
}
}
})
return purify.sanitize(children, { ADD_ATTR: ['referrer-policy'] })
}, [children])
return <Fragment dangerouslySetInnerHTML={{ __html: purified }} />
}
+2
View File
@@ -136,6 +136,8 @@ const FormLogin = ({ loading, handleSubmit, validate }) => {
{config.welcomeMessage && (
<div
className={classes.welcome}
// Use dangerouslySetInnerHTML to allow admins to configure
// whatever content they want
dangerouslySetInnerHTML={{ __html: config.welcomeMessage }}
/>
)}