From 3e5c1e983014d03552e8b31ea74615ad8ffbe330 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 10:11:50 +0300 Subject: [PATCH] security(push): sanitize message-editor content before it enters the contenteditable The push message editor set the composed message as innerHTML on a live contenteditable. Sanitize that content with countlyCommon.encodeSomeHtml, allowing only the user-property token span (and the attributes it relies on: class, id, contenteditable, data-user-property-*) and escaping any other markup to inert text. The message body is user text and the token element is the only legitimate markup, so display is unchanged for normal messages; the token id is preserved so the editor's per-token event wiring keeps working. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + .../javascripts/countly.views.component.common.js | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ace8a79ae..1f689fdcc78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Enterprise Fixes: - [data-manager] Fixed editing an event whose key contains `&` creating undeletable duplicate rows in the events table Security Fixes: +- [push] The message editor now sanitizes message content before rendering it into the editor, allowing only the user-property token element and rendering any other markup as text - [hooks] Internal event hooks are now scoped to the apps the hook belongs to: app creation is a global-admin-only event, and remote-config, cohort, alert and hook-chaining events are only delivered when the event's app is one the hook is scoped to - [compliance-hub] The consents table now returns a fixed set of fields; a projection supplied on the request is no longer used to widen the response beyond the consent columns - [dashboards] Widgets are no longer copied when the copying user has no access to the apps they reference, and widget app ids are validated on widget create and update diff --git a/plugins/push/frontend/public/javascripts/countly.views.component.common.js b/plugins/push/frontend/public/javascripts/countly.views.component.common.js index 84082df30e1..765fd61b207 100644 --- a/plugins/push/frontend/public/javascripts/countly.views.component.common.js +++ b/plugins/push/frontend/public/javascripts/countly.views.component.common.js @@ -1,6 +1,15 @@ /* eslint-disable no-console */ /*global CV,countlyVue,countlyPushNotification,countlyGlobal,countlyCommon,moment*/ (function(countlyPushNotificationComponent) { + // The message editor is a live contenteditable. Its body is user-authored text; the + // only legitimate markup is the user-property token . Allow just that element + // (with the attributes the token relies on) and let everything else be escaped to inert + // text, so a stored message cannot introduce active markup when the editor is populated. + var PUSH_MESSAGE_EDITOR_XSS_OPTIONS = { + whiteList: { + span: ["class", "id", "contenteditable", "data-user-property-label", "data-user-property-value", "data-user-property-fallback"] + } + }; countlyPushNotificationComponent.LargeRadioButtonWithDescription = countlyVue.views.create({ props: { value: { @@ -706,7 +715,7 @@ }, reset: function(htmlContent, ids) { this.disconnectMutationObserver(); - this.$refs.element.innerHTML = htmlContent; + this.$refs.element.innerHTML = countlyCommon.encodeSomeHtml(htmlContent, PUSH_MESSAGE_EDITOR_XSS_OPTIONS); this.addEventListeners(ids); this.startMutationObserver(); },