Files
dc/client/src/app/editor/utils/renderers.utils.ts
T
hermes cfd8f06435 fix(security): escape cell values in status renderers to prevent DOM XSS
The error/no-spinner/spinner cell renderers wrote the cell value straight
into td.innerHTML. A value containing markup (which can arrive from a dataset
served by the getdata stored program or from a typed edit) was therefore
parsed and executed by the browser. Escape the value so it renders as inert
text, keeping the hardcoded icon markup intact, and add a regression test
that reproduces the injection via a real Handsontable instance.
2026-09-14 16:44:59 +01:00

142 lines
3.4 KiB
TypeScript

import Handsontable from 'handsontable'
/**
* Returns string-safe text of any value so it can be assigned to innerHTML.
* The cell values painted by the status renderers are user/DB-controlled,
* so they must never be parsed as HTML by the browser — escaping turns any
* embedded markup into inert text.
*/
const escapeHtml = (value: any): string =>
String(value ?? '').replace(/[&<>"']/g, (char) => {
const entities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
return entities[char]
})
/**
* Builds a display-only HOT renderer that formats numeric cell values using
* Intl.NumberFormat. The stored/submitted value is never changed — only the
* rendered text. `ruleValue` is a JSON string of Intl.NumberFormat options
* (e.g. '{"style":"currency","currency":"EUR","minimumFractionDigits":2}').
*
* Falls back to the plain text renderer when the JSON is invalid, the options
* are rejected by Intl.NumberFormat, or the value is not a finite number.
*/
export const makeNumberFormatRenderer = (ruleValue?: string) => {
let formatter: Intl.NumberFormat | null = null
try {
const options = ruleValue ? JSON.parse(ruleValue) : {}
formatter = new Intl.NumberFormat(window.navigator.language, options)
} catch (e) {
console.warn(
`NUMBER_FORMAT - invalid Intl.NumberFormat options: ${ruleValue}`
)
formatter = null
}
const baseRenderer = Handsontable.renderers.getRenderer('text')
return (
instance: any,
td: any,
row: number,
col: number,
prop: string | number,
value: any,
cellProperties: any
) => {
// Render via the base text renderer first to preserve cell styling/classes
// (readOnly, alignment, etc.), then override the displayed text.
baseRenderer(instance, td, row, col, prop, value, cellProperties)
const num = Number(value)
if (
formatter &&
value !== null &&
value !== undefined &&
value !== '' &&
!isNaN(num)
) {
td.textContent = formatter.format(num)
}
return td
}
}
/**
* Custom renderer for HOT cell
* Used to show error icon
*/
export const errorRenderer = (
instance: any,
td: any,
row: number,
col: number,
prop: string,
value: any,
cellProperties: any
) => {
addDarkClass(td)
td.innerHTML = `${escapeHtml(value)} <cds-icon shape="exclamation-triangle" status="warning"></cds-icon>`
return td
}
/**
* Custom renderer for HOT cell
* Used to revert cell back to original state (no spinner, no error)
*/
export const noSpinnerRenderer = (
instance: any,
td: any,
row: number,
col: number,
prop: string,
value: any,
cellProperties: any
) => {
addDarkClass(td)
td.innerHTML = escapeHtml(value)
return td
}
/**
* Custom renderer for HOT cell
* Used to show loading spinner in the cell
* (Spinner shown whilst waiting for SAS to respond)
*/
export const spinnerRenderer = (
instance: any,
td: any,
row: number,
col: number,
prop: string,
value: any,
cellProperties: any
) => {
addDarkClass(td)
td.innerHTML = `${escapeHtml(value)} <span class="spinner spinner-sm vertical-align-middle"></span>`
return td
}
/**
* Adds a htDark class to a TD element if not existing
*/
const addDarkClass = (td: any) => {
if (!td.classList.contains('htDark')) {
td.classList.add('htDark')
}
}