Skip to content

Commit

Permalink
fix(DHIS2-17668): sanitise HTML in table instead of showing it as text
Browse files Browse the repository at this point in the history
  • Loading branch information
kabaros committed Aug 21, 2024
1 parent 714d490 commit cb754aa
Show file tree
Hide file tree
Showing 6 changed files with 281 additions and 63 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@dhis2/app-runtime": "^3.2.1",
"@dhis2/prop-types": "^1.6.4",
"@dhis2/ui": "^7.2.7",
"dompurify": "^3.1.6",
"history": "^5.0.1",
"prop-types": "^15.7.2",
"query-string": "^7.0.1",
Expand Down
2 changes: 1 addition & 1 deletion src/app-context/app-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const query = {
'displayName',
'dataApprovalLevels',
'periodType',
'dataSets[id,displayName,periodType]',
'dataSets[id,displayName,periodType,formType]',
],
},
},
Expand Down
15 changes: 15 additions & 0 deletions src/data-workspace/display/display.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
RetryButton,
} from '../../shared/index.js'
import styles from './display.module.css'
import { TableCustomDataSet } from './table-custom-data-set.js'
import { Table } from './table.js'

const query = {
Expand Down Expand Up @@ -123,6 +124,20 @@ const Display = ({ dataSetId }) => {
)
}

if (selectedDataSet.formType === 'CUSTOM') {
return (
<div className={styles.display}>
{tables.map((table) => (
<TableCustomDataSet
key={table.title}
title={table.title}
columns={table.headers.map((h) => h.name)}
rows={table.rows}
/>
))}
</div>
)
}
return (
<div className={styles.display}>
{tables.map((table) => (
Expand Down
217 changes: 155 additions & 62 deletions src/data-workspace/display/display.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,6 @@ describe('<Display>', () => {
periodType: 'Monthly',
}

const dataSetThree = {
displayName: 'Another',
id: 'custom',
periodType: 'Monthly',
}

it('asks the user to select a data set if none is selected', () => {
render(
<CustomDataProvider options={{ loadForever: true }}>
Expand Down Expand Up @@ -223,64 +217,163 @@ describe('<Display>', () => {
).toBeInTheDocument()
})

it('renders one table per data set in the report', async () => {
const data = {
dataSetReport: [
{
title: 'Data set 1',
headers: [{ name: 'Header 1' }, { name: 'Header 2' }],
rows: [],
},
{
title: 'Data set 2',
headers: [{ name: 'Header 1' }, { name: 'Header 2' }],
rows: [],
},
{
title: 'Data set 3',
headers: [{ name: 'Header 1' }, { name: 'Header 2' }],
rows: [],
},
],
}
render(
<CustomDataProvider data={data}>
<SelectionContext.Provider
value={{
orgUnit: {
id: 'ou-2',
path: '/ou-2',
displayName: 'Org unit 2',
},
period: {
displayName: 'January 2021',
startDate: '2021-01-01',
endDate: '2021-01-31',
year: 2021,
iso: '202101',
id: '202101',
},
workflow: {
dataSets: [dataSetOne, dataSetTwo],
dataApprovalLevels: [],
displayName: 'Workflow 1',
periodType: 'Monthly',
id: 'foo',
},
}}
>
<Display dataSetId="pBOMPrpg1QX" />
</SelectionContext.Provider>
</CustomDataProvider>
)
describe('display for custom datasets', () => {
it('renders a table for a custom dataset with safely sanitised HTML and CSS', async () => {
const data = {
dataSetReport: [
{
title: 'Custom Data set',
headers: [
{
name: '<b><span style="color:#00b050">2024/25</span></b>',
column: '<b><span style="color:#00b050">2024/25</span></b>',
type: 'java.lang.String',
hidden: false,
meta: false,
},
{
name: '<span style="color:black">NATIONAL DEPARTMENT OF HEALTH</span>',
column: '<span style="color:black">NATIONAL DEPARTMENT OF HEALTH</span>',
type: 'java.lang.String',
hidden: false,
meta: false,
},
],
rows: [
[
'<span style="color:black">Programme 6: Performance Indicator</span>',
],
],
},
],
}
render(
<CustomDataProvider data={data}>
<SelectionContext.Provider
value={{
orgUnit: {
id: 'ou-2',
path: '/ou-2',
displayName: 'Org unit 2',
},
period: {
displayName: 'January 2021',
startDate: '2021-01-01',
endDate: '2021-01-31',
year: 2021,
iso: '202101',
id: '202101',
},
workflow: {
dataSets: [
{
displayName: 'Another',
id: 'custom',
periodType: 'Monthly',
formType: 'CUSTOM',
},
],
dataApprovalLevels: [],
displayName: 'Workflow 1',
periodType: 'Monthly',
id: 'foo',
},
}}
>
<Display dataSetId="custom" />
</SelectionContext.Provider>
</CustomDataProvider>
)

await waitForElementToBeRemoved(() => screen.getByRole('progressbar'))
await waitForElementToBeRemoved(() =>
screen.getByRole('progressbar')
)

expect(await screen.findAllByRole('table')).toHaveLength(3)
expect(
await screen.queryByText(
/This data set does not use a default form. The data is displayed as a simple grid below, but this might not be a suitable representation..*/
expect(screen.getByText('2024/25')).toHaveStyle({
color: 'rgb(0, 176, 80)',
})
expect(screen.getByText('2024/25').parentElement.tagName).toBe('B')

expect(
screen.getByText('NATIONAL DEPARTMENT OF HEALTH')
).toHaveStyle({
color: 'black',
})

expect(
screen.getByText('Programme 6: Performance Indicator')
).toHaveStyle({
color: 'black',
})
})

it('renders HTML and CSS encoded for non-custom dataset', async () => {
const data = {
dataSetReport: [
{
title: 'Custom Data set',
headers: [
{
name: '<span style="color:black">NATIONAL DEPARTMENT OF HEALTH</span>',
column: '<span style="color:black">NATIONAL DEPARTMENT OF HEALTH</span>',
type: 'java.lang.String',
hidden: false,
meta: false,
},
],
rows: [
[
'<span style="color:black">Programme 6: Performance Indicator</span>',
],
],
},
],
}
render(
<CustomDataProvider data={data}>
<SelectionContext.Provider
value={{
orgUnit: {
id: 'ou-2',
path: '/ou-2',
displayName: 'Org unit 2',
},
period: {
displayName: 'January 2021',
startDate: '2021-01-01',
endDate: '2021-01-31',
year: 2021,
iso: '202101',
id: '202101',
},
workflow: {
dataSets: [
{
displayName: 'Another',
id: 'custom',
periodType: 'Monthly',
formType: 'Default',
},
],
dataApprovalLevels: [],
displayName: 'Workflow 1',
periodType: 'Monthly',
id: 'foo',
},
}}
>
<Display dataSetId="custom" />
</SelectionContext.Provider>
</CustomDataProvider>
)

await waitForElementToBeRemoved(() =>
screen.getByRole('progressbar')
)
).not.toBeInTheDocument()

expect(screen.getByRole('table')).toContainHTML(
'&lt;span style="color:black"&gt;Programme 6: Performance Indicator&lt;/span&gt;'
)
})
})
})
104 changes: 104 additions & 0 deletions src/data-workspace/display/table-custom-data-set.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
DataTable,
TableHead,
DataTableRow,
DataTableColumnHeader,
TableBody,
DataTableCell,
} from '@dhis2/ui'
import DOMPurify from 'dompurify'
import PropTypes from 'prop-types'
import React from 'react'
import styles from './table.module.css'

// Needs to have the same width as the table, so can't use the one from
// @dhis2/ui
const DataTableToolbar = ({ children, columns }) => (
<tr>
<th className={styles.titleCell} colSpan={columns.toString()}>
{children}
</th>
</tr>
)

DataTableToolbar.propTypes = {
children: PropTypes.any.isRequired,
columns: PropTypes.number.isRequired,
}

const TableCustomDataSet = ({ title, columns, rows }) => (
<>
<DataTable className={styles.dataTable} width="auto">
<TableHead>
<DataTableToolbar columns={columns.length}>
{title}
</DataTableToolbar>
<DataTableRow>
<DataTableColumnHeader className={styles.cell}>
<span
className={styles.labelCellContent}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(columns[0]),
}}
></span>
</DataTableColumnHeader>

{columns.slice(1).map((column) => {
return (
<DataTableColumnHeader
key={column}
className={styles.cell}
>
<span
className={styles.labelCellContent}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(column),
}}
></span>
</DataTableColumnHeader>
)
})}
</DataTableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => {
const [firstCell, ...cells] = row

return (
<DataTableRow key={index}>
<DataTableCell className={styles.cell}>
<span
className={styles.labelCellContent}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(firstCell),
}}
></span>
</DataTableCell>

{cells.map((value, index) => (
<DataTableCell
key={index}
className={styles.cell}
>
<span
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(value),
}}
></span>
</DataTableCell>
))}
</DataTableRow>
)
})}
</TableBody>
</DataTable>
</>
)

TableCustomDataSet.propTypes = {
columns: PropTypes.array.isRequired,
rows: PropTypes.array.isRequired,
title: PropTypes.string.isRequired,
}

export { TableCustomDataSet }
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7819,6 +7819,11 @@ domhandler@^4.0.0, domhandler@^4.2.0:
dependencies:
domelementtype "^2.2.0"

dompurify@^3.1.6:
version "3.1.6"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.6.tgz#43c714a94c6a7b8801850f82e756685300a027e2"
integrity sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==

domutils@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
Expand Down

0 comments on commit cb754aa

Please sign in to comment.