From cce2e961db08b5d591ef34920c61a0f7e7d810e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20J=C3=A4ckle?= Date: Fri, 17 May 2024 15:42:53 +0200 Subject: [PATCH] sanitize user input when passing it to `innerHTML` in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * use dompurify library for that * replaced many `innerHTML` assignments with `textContent` Signed-off-by: Thomas Jäckle --- ui/modules/connections/connections.ts | 12 +++--- ui/modules/connections/connectionsMonitor.ts | 6 +-- ui/modules/environments/environments.ts | 6 +-- ui/modules/operations/operations.ts | 4 +- ui/modules/policies/policies.ts | 2 +- ui/modules/things/attributes.ts | 2 +- ui/modules/things/featureMessages.ts | 4 +- ui/modules/things/features.ts | 2 +- ui/modules/things/fields.ts | 8 ++-- ui/modules/things/messagesIncoming.ts | 2 +- ui/modules/things/searchFilter.ts | 8 ++-- ui/modules/things/thingMessages.ts | 4 +- ui/modules/things/thingsCRUD.ts | 2 +- ui/modules/things/thingsSearch.ts | 20 +++++---- ui/modules/utils.ts | 45 ++++++++------------ ui/package-lock.json | 6 +++ ui/package.json | 1 + 17 files changed, 66 insertions(+), 68 deletions(-) diff --git a/ui/modules/connections/connections.ts b/ui/modules/connections/connections.ts index f44eb03419..b87c3ef9e8 100644 --- a/ui/modules/connections/connections.ts +++ b/ui/modules/connections/connections.ts @@ -16,7 +16,7 @@ /* eslint-disable require-jsdoc */ import * as API from '../api.js'; import * as Utils from '../utils.js'; -import {TabHandler} from '../utils/tabHandler.js'; +import { TabHandler } from '../utils/tabHandler.js'; import connectionsHTML from './connections.html'; const observers = []; @@ -57,7 +57,7 @@ export function setConnection(connection, isNewConnection = false) { } export function loadConnections() { - dom.tbodyConnections.innerHTML = ''; + dom.tbodyConnections.textContent = ''; let connectionSelected = false; API.callConnectionsAPI('listConnections', (connections) => { connections.forEach((connection) => { @@ -66,15 +66,15 @@ export function loadConnections() { row.id = id; if (API.env() === 'ditto_2') { API.callConnectionsAPI('retrieveConnection', (dittoConnection) => { - row.insertCell(0).innerHTML = dittoConnection.name; + row.insertCell(0).textContent = dittoConnection.name; }, id); } else { - row.insertCell(0).innerHTML = connection.name ? connection.name : id; + row.insertCell(0).textContent = connection.name ? connection.name : id; } API.callConnectionsAPI('retrieveStatus', (status) => { - row.insertCell(-1).innerHTML = status.liveStatus; - row.insertCell(-1).innerHTML = status.recoveryStatus; + row.insertCell(-1).textContent = status.liveStatus; + row.insertCell(-1).textContent = status.recoveryStatus; }, id); if (id === selectedConnectionId) { diff --git a/ui/modules/connections/connectionsMonitor.ts b/ui/modules/connections/connectionsMonitor.ts index 9934a4ba6e..0c43793c9f 100644 --- a/ui/modules/connections/connectionsMonitor.ts +++ b/ui/modules/connections/connectionsMonitor.ts @@ -86,7 +86,7 @@ function onEnableConnectionLogsClick() { function retrieveConnectionMetrics() { Utils.assert(selectedConnectionId, 'Please select a connection', dom.tableValidationConnections); - dom.tbodyConnectionMetrics.innerHTML = ''; + dom.tbodyConnectionMetrics.textContent = ''; API.callConnectionsAPI('retrieveConnectionMetrics', (response) => { if (response.connectionMetrics) { Object.keys(response.connectionMetrics).forEach((direction) => { @@ -149,8 +149,8 @@ function onConnectionChange(connection, isNewConnection = true) { selectedConnectionId = connection ? connection.id : null; connectionStatusDetail.setValue(''); connectionLogDetail.setValue(''); - dom.tbodyConnectionMetrics.innerHTML = ''; - dom.tbodyConnectionLogs.innerHTML = ''; + dom.tbodyConnectionMetrics.textContent = ''; + dom.tbodyConnectionLogs.textContent = ''; if (!isNewConnection && connection && connection.id) { retrieveConnectionLogs(); } diff --git a/ui/modules/environments/environments.ts b/ui/modules/environments/environments.ts index ac1c8ffec4..f2e7cc31ef 100644 --- a/ui/modules/environments/environments.ts +++ b/ui/modules/environments/environments.ts @@ -11,13 +11,13 @@ * SPDX-License-Identifier: EPL-2.0 */ +import * as Utils from '../utils.js'; /* eslint-disable arrow-parens */ /* eslint-disable prefer-const */ /* eslint-disable require-jsdoc */ import * as Authorization from './authorization.js'; -import * as Utils from '../utils.js'; -import defaultTemplates from './environmentTemplates.json'; import environmentsHTML from './environments.html'; +import defaultTemplates from './environmentTemplates.json'; const URL_PRIMARY_ENVIRONMENT_NAME = 'primaryEnvironmentName'; @@ -222,7 +222,7 @@ export function environmentsJsonChanged(modifiedField = null) { } function updateEnvTable() { - dom.tbodyEnvironments.innerHTML = ''; + dom.tbodyEnvironments.textContent = ''; Object.keys(environments).forEach((key) => { Utils.addTableRow(dom.tbodyEnvironments, key, key === selectedEnvName); }); diff --git a/ui/modules/operations/operations.ts b/ui/modules/operations/operations.ts index 11d943820d..449843a162 100644 --- a/ui/modules/operations/operations.ts +++ b/ui/modules/operations/operations.ts @@ -47,12 +47,12 @@ function loadAllLogLevels() { API.callDittoREST('GET', '/devops/logging', null, null, false, true) .then((result) => createLoggerView(result)) .catch((error) => { - dom.divLoggers.innerHTML = ''; + dom.divLoggers.textContent = ''; }); } function createLoggerView(allLogLevels) { - dom.divLoggers.innerHTML = ''; + dom.divLoggers.textContent = ''; type LogLevel = { loggerConfigs?: object[] diff --git a/ui/modules/policies/policies.ts b/ui/modules/policies/policies.ts index fc12d1836a..8d82aa5719 100644 --- a/ui/modules/policies/policies.ts +++ b/ui/modules/policies/policies.ts @@ -227,7 +227,7 @@ function onThingChanged(thing) { } function refreshWhoAmI() { - dom.tbodyWhoami.innerHTML = ''; + dom.tbodyWhoami.textContent = ''; API.callDittoREST('GET', '/whoami') .then((whoamiResult) => { whoamiResult.subjects.forEach((subject) => { diff --git a/ui/modules/things/attributes.ts b/ui/modules/things/attributes.ts index 913326b9f4..9890c42f3d 100644 --- a/ui/modules/things/attributes.ts +++ b/ui/modules/things/attributes.ts @@ -115,7 +115,7 @@ function refreshAttribute(thing, attributePath = null) { function onThingChanged(thing) { dom.crudAttribute.editDisabled = (thing === null); - dom.tbodyAttributes.innerHTML = ''; + dom.tbodyAttributes.textContent = ''; let count = 0; let thingHasAttribute = false; if (thing && thing.attributes) { diff --git a/ui/modules/things/featureMessages.ts b/ui/modules/things/featureMessages.ts index 8005832511..78405ac9ac 100644 --- a/ui/modules/things/featureMessages.ts +++ b/ui/modules/things/featureMessages.ts @@ -163,11 +163,11 @@ function clearAllFields() { dom.inputMessageTimeout.value = '10'; acePayload.setValue(''); aceResponse.setValue(''); - dom.ulMessageTemplates.innerHTML = ''; + dom.ulMessageTemplates.textContent = ''; } function refillTemplates() { - dom.ulMessageTemplates.innerHTML = ''; + dom.ulMessageTemplates.textContent = ''; Utils.addDropDownEntries(dom.ulMessageTemplates, ['Saved message templates'], true); if (theFeatureId && Environments.current().messageTemplates[theFeatureId]) { Utils.addDropDownEntries( diff --git a/ui/modules/things/features.ts b/ui/modules/things/features.ts index c85cacb67f..2834a530bc 100644 --- a/ui/modules/things/features.ts +++ b/ui/modules/things/features.ts @@ -219,7 +219,7 @@ function refreshFeature(thing, featureId = null) { function onThingChanged(thing) { dom.crudFeature.editDisabled = (thing === null); // Update features table - dom.tbodyFeatures.innerHTML = ''; + dom.tbodyFeatures.textContent = ''; let count = 0; let thingHasFeature = false; if (thing && thing.features) { diff --git a/ui/modules/things/fields.ts b/ui/modules/things/fields.ts index 172e45fe96..6d345ed48f 100644 --- a/ui/modules/things/fields.ts +++ b/ui/modules/things/fields.ts @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -import {Modal} from 'bootstrap'; +import { Modal } from 'bootstrap'; import * as Environments from '../environments/environments.js'; import * as Utils from '../utils.js'; @@ -165,14 +165,14 @@ function onEnvironmentChanged() { * (Re-)Initializes the fieldlist in the UI */ function updateFieldList() { - dom.fieldList.innerHTML = ''; + dom.fieldList.textContent = ''; theFieldIndex = -1; Environments.current().fieldList.forEach((field, i) => { const fieldSelected = dom.fieldPath.value === field.path; const row = dom.fieldList.insertRow(); Utils.addCheckboxToRow(row, i, field.active, toggleFieldActiveEventHandler); - row.insertCell(-1).innerHTML = field.path; - row.insertCell(-1).innerHTML = field['label'] ? field.label : null; + row.insertCell(-1).textContent = field.path; + row.insertCell(-1).textContent = field['label'] ? field.label : null; if (fieldSelected) { theFieldIndex = i; row.classList.add('table-active'); diff --git a/ui/modules/things/messagesIncoming.ts b/ui/modules/things/messagesIncoming.ts index b9241bdc31..f5938d92c2 100644 --- a/ui/modules/things/messagesIncoming.ts +++ b/ui/modules/things/messagesIncoming.ts @@ -53,7 +53,7 @@ function onMessageTableClick(event) { function onResetMessagesClick() { messages = []; dom.badgeMessageIncomingCount.textContent = ''; - dom.tbodyMessagesIncoming.innerHTML = ''; + dom.tbodyMessagesIncoming.textContent = ''; messageDetail.setValue(''); } diff --git a/ui/modules/things/searchFilter.ts b/ui/modules/things/searchFilter.ts index 83f0542f4a..6ff2238183 100644 --- a/ui/modules/things/searchFilter.ts +++ b/ui/modules/things/searchFilter.ts @@ -61,8 +61,7 @@ export async function ready() { }); dom.searchThings.onclick = () => { - fillHistory(dom.searchFilterEdit.value); - ThingsSearch.searchTriggered(dom.searchFilterEdit.value); + ThingsSearch.searchTriggered(dom.searchFilterEdit.value, () => fillHistory(dom.searchFilterEdit.value)); }; dom.searchFavorite.onclick = () => { @@ -74,8 +73,7 @@ export async function ready() { dom.searchFilterEdit.onkeyup = (event) => { if ((event.key === 'Enter' || event.code === 13) && dom.searchFilterEdit.value.indexOf(FILTER_PLACEHOLDER) < 0) { - fillHistory(dom.searchFilterEdit.value); - ThingsSearch.searchTriggered(dom.searchFilterEdit.value); + ThingsSearch.searchTriggered(dom.searchFilterEdit.value, () => fillHistory(dom.searchFilterEdit.value)); } else { clearTimeout(keyStrokeTimeout); keyStrokeTimeout = setTimeout(checkIfFavorite, 1000); @@ -107,7 +105,7 @@ function fillSearchFilterEdit(fillString) { checkIfFavorite(); const filterEditNeeded = checkAndMarkParameter(); if (!filterEditNeeded) { - ThingsSearch.searchTriggered(dom.searchFilterEdit.value); + ThingsSearch.searchTriggered(dom.searchFilterEdit.value, () => null); } } diff --git a/ui/modules/things/thingMessages.ts b/ui/modules/things/thingMessages.ts index 58446cb390..ad27858c63 100644 --- a/ui/modules/things/thingMessages.ts +++ b/ui/modules/things/thingMessages.ts @@ -152,11 +152,11 @@ function clearAllFields() { dom.inputThingMessageTimeout.value = '10'; acePayload.setValue(''); aceResponse.setValue(''); - dom.ulThingMessageTemplates.innerHTML = ''; + dom.ulThingMessageTemplates.textContent = ''; } function refillTemplates() { - dom.ulThingMessageTemplates.innerHTML = ''; + dom.ulThingMessageTemplates.textContent = ''; Utils.addDropDownEntries(dom.ulThingMessageTemplates, ['Saved message templates'], true); if (Environments.current().messageTemplates['/']) { Utils.addDropDownEntries( diff --git a/ui/modules/things/thingsCRUD.ts b/ui/modules/things/thingsCRUD.ts index 52c51d0c95..bb6521487a 100644 --- a/ui/modules/things/thingsCRUD.ts +++ b/ui/modules/things/thingsCRUD.ts @@ -124,7 +124,7 @@ function onThingChanged(thingJson) { updateThingJsonEditor(); function updateThingDetailsTable() { - dom.tbodyThingDetails.innerHTML = ''; + dom.tbodyThingDetails.textContent = ''; if (thingJson) { Utils.addTableRow(dom.tbodyThingDetails, 'thingId', false, true, thingJson.thingId); Utils.addTableRow(dom.tbodyThingDetails, 'policyId', false, true, thingJson.policyId); diff --git a/ui/modules/things/thingsSearch.ts b/ui/modules/things/thingsSearch.ts index a2c8dcb460..1d7541afe5 100644 --- a/ui/modules/things/thingsSearch.ts +++ b/ui/modules/things/thingsSearch.ts @@ -20,8 +20,8 @@ import { JSONPath } from 'jsonpath-plus'; import * as API from '../api.js'; import * as Environments from '../environments/environments.js'; - import * as Utils from '../utils.js'; +import { sanitizeHTML } from '../utils.js'; import * as Fields from './fields.js'; import * as Things from './things.js'; import * as ThingsSSE from './thingsSSE.js'; @@ -75,12 +75,14 @@ function onThingsTableClicked(event) { /** * Tests if the search filter is an RQL. If yes, things search is called otherwise just things get * @param {String} filter search filter string containing an RQL or a thingId + * @param rqlFilterCallback a callback to invoke when the passed `filter` was a valid RQL statement */ -export function searchTriggered(filter) { +export function searchTriggered(filter: string, rqlFilterCallback: () => void) { lastSearch = filter; const regex = /^(eq\(|ne\(|gt\(|ge\(|lt\(|le\(|in\(|like\(|ilike\(|exists\(|and\(|or\(|not\().*/; if (filter === '' || regex.test(filter)) { searchThings(filter); + rqlFilterCallback(); } else { getThings([filter]); } @@ -104,7 +106,7 @@ export function performLastSearch() { if (lastSearch === 'pinned') { pinnedTriggered(); } else { - searchTriggered(lastSearch); + searchTriggered(lastSearch, () => null); } } @@ -113,7 +115,7 @@ export function performLastSearch() { * @param {Array} thingIds Array of thingIds */ export function getThings(thingIds) { - dom.thingsTableBody.innerHTML = ''; + dom.thingsTableBody.textContent = ''; const fieldsQueryParameter = Fields.getQueryParameter(); if (thingIds.length > 0) { API.callDittoREST('GET', @@ -134,8 +136,8 @@ export function getThings(thingIds) { function resetAndClearViews(retainThing = false) { theSearchCursor = null; - dom.thingsTableHead.innerHTML = ''; - dom.thingsTableBody.innerHTML = ''; + dom.thingsTableHead.textContent = ''; + dom.thingsTableBody.textContent = ''; if (!retainThing) { Things.setTheThing(null); } @@ -187,7 +189,7 @@ function searchThings(filter, isMore = false) { function addMoreToThingList() { const moreCell = dom.thingsTableBody.insertRow().insertCell(-1); - moreCell.innerHTML = 'load more...'; + moreCell.textContent = 'load more...'; moreCell.colSpan = dom.thingsTableBody.rows[0].childElementCount; moreCell.style.textAlign = 'center'; moreCell.style.cursor = 'pointer'; @@ -225,7 +227,7 @@ function fillThingsTable(thingsList) { } function fillHeaderRow() { - dom.thingsTableHead.innerHTML = ''; + dom.thingsTableHead.textContent = ''; // Utils.addCheckboxToRow(dom.thingsTableHead, 'checkboxHead', false, null); Utils.insertHeaderCell(dom.thingsTableHead, ''); Utils.insertHeaderCell(dom.thingsTableHead, 'Thing ID'); @@ -302,7 +304,7 @@ export function updateTableRow(thingUpdateJson) { path: path, }); if (elem.length !== 0) { - cell.innerHTML = elem[0]; + cell.innerHTML = sanitizeHTML(elem[0]); } } }); diff --git a/ui/modules/utils.ts b/ui/modules/utils.ts index f55cab12a2..37a4dc1fde 100644 --- a/ui/modules/utils.ts +++ b/ui/modules/utils.ts @@ -15,6 +15,7 @@ import autoComplete from '@tarekraafat/autocomplete.js'; import * as ace from 'ace-builds/src-noconflict/ace'; import { Modal, Toast } from 'bootstrap'; +import * as DOMPurify from 'dompurify'; const dom = { @@ -84,7 +85,7 @@ export function addCheckboxToRow(row, id, checked, onToggle) { */ export function addCellToRow(row, cellContent, cellTooltip = null, position = -1) { const cell = row.insertCell(position); - cell.innerHTML = cellContent; + cell.textContent = cellContent; cell.setAttribute('data-bs-toggle', 'tooltip'); cell.title = cellTooltip ?? cellContent; return cell; @@ -115,36 +116,17 @@ export function addClipboardCopyToRow(row: HTMLTableRowElement) { */ export function insertHeaderCell(row, label) { const th = document.createElement('th'); - th.innerHTML = label; + th.textContent = label; row.appendChild(th); } -/** - * Create a radio button element - * @param {HTMLElement} target target element - * @param {String} groupName group for consecutive added radio buttons - * @param {String} value name of the radio button - * @param {boolean} checked check the radio button - */ -export function addRadioButton(target, groupName, value, checked) { - const radio = document.createElement('div'); - radio.innerHTML = `
- - -
`; - target.appendChild(radio); -} - /** * Create a list of option elements * @param {HTMLElement} target target element (select) * @param {array} options Array of strings to be filled as options */ export function setOptions(target, options) { - target.innerHTML = ''; + target.textContent = ''; options.forEach((key) => { const option = document.createElement('option'); option.text = key; @@ -211,6 +193,15 @@ export function getAllElementsById(domObjects: object, searchRoot: DocumentFragm }); } +/** + * Sanitizes the passed unsafeString to be safely used e.g. inside innerHTML without risking XSS. + + * @param unsafeString the string to sanitize. + */ +export function sanitizeHTML(unsafeString: String) { + return DOMPurify.sanitize(unsafeString); +} + /** * Show an error toast * @param {String} message Message for toast @@ -222,11 +213,11 @@ export function showError(message, header = 'Error', status = '') { domToast.classList.add('toast'); domToast.innerHTML = `
- ${header} + ${sanitizeHTML(header)} ${status}
-
${message}
`; +
${sanitizeHTML(message)}
`; dom.toastContainer.appendChild(domToast); domToast.addEventListener("hidden.bs.toast", () => { @@ -258,7 +249,7 @@ export function assert(condition, message, validatedElement = null) { } if (!condition) { if (validatedElement) { - validatedElement.parentNode.getElementsByClassName('invalid-feedback')[0].innerHTML = message; + validatedElement.parentNode.getElementsByClassName('invalid-feedback')[0].textContent = message; validatedElement.classList.add('is-invalid'); } else { showError(message, 'Error'); @@ -291,7 +282,7 @@ let modalConfirm; */ export function confirm(message, action, callback) { modalConfirm = modalConfirm ?? new Modal('#modalConfirm'); - dom.modalBodyConfirm.innerHTML = message; + dom.modalBodyConfirm.textContent = message; dom.buttonConfirmed.innerText = action; dom.buttonConfirmed.onclick = callback; modalConfirm.show(); @@ -341,7 +332,7 @@ export function createAutoComplete(selector, src, placeHolder) { highlight: true, element: (item, data) => { item.style = 'display: flex;'; - item.innerHTML = `${data.key === 'label' ? data.match : data.value.label} + item.innerHTML = `${sanitizeHTML(data.key === 'label' ? data.match : data.value.label)} ${data.key === 'group' ? data.match : data.value.group}`; }, diff --git a/ui/package-lock.json b/ui/package-lock.json index 671b92bdde..63e607b9de 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -14,6 +14,7 @@ "ace-builds": "^1.23.2", "bootstrap": "^5.2.3", "bootstrap-icons": "^1.10.5", + "dompurify": "^3.1.3", "event-source-polyfill": "^1.0.31", "jsonpath-plus": "^7.2.0" }, @@ -962,6 +963,11 @@ "node": ">=6.0.0" } }, + "node_modules/dompurify": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.3.tgz", + "integrity": "sha512-5sOWYSNPaxz6o2MUPvtyxTTqR4D3L77pr5rUQoWgD5ROQtVIZQgJkXbo1DLlK3vj11YGw5+LnF4SYti4gZmwng==" + }, "node_modules/es-abstract": { "version": "1.21.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.21.2.tgz", diff --git a/ui/package.json b/ui/package.json index 4cec964833..0216ee8c8a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -24,6 +24,7 @@ "dependencies": { "@popperjs/core": "^2.11.7", "@tarekraafat/autocomplete.js": "^10.2.7", + "dompurify": "^3.1.3", "ace-builds": "^1.23.2", "bootstrap": "^5.2.3", "bootstrap-icons": "^1.10.5",