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:
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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 }} />
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user