Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ Fixes:
Enterprise Fixes:
- [data-manager] Fixed editing an event whose key contains `&` creating undeletable duplicate rows in the events table

Security Fixes:
- [core] The graph note tooltip now HTML-encodes the application name before rendering, so an application name is shown as text rather than markup
- [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
- [compliance-hub] The export/purge history table now HTML-encodes the application name before it is placed in the action cell, so an application name is shown as text rather than markup
- [populator] The populator confirmation dialog bodies are now rendered as text instead of HTML
- [core] The dashboard escapes `<` when serializing the exposed `countlyGlobal` object into the inline page script, so an application name (or any exposed value) containing a `</script>` end tag in any spelling can no longer break out of the script block and run in another user's session; the active-app name is now rendered with `.text()` instead of `.html()`

## Version 24.05.51

Fixes:
Expand Down
13 changes: 9 additions & 4 deletions frontend/express/libs/express-expose.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,13 @@ function string(obj) {
else {
obj = JSON.stringify(obj);
if (obj) {
// Only escape things that could break out of script context
obj = obj.replace(/<\/script>/ig, '</scr"+"ipt>');
obj = obj.replace(/<!--/g, '<\\!--');
// Escape "<" so nothing serialized here can open or close a tag in the inline
// <script> block this value is written into. A "</script>" end tag terminates the
// script in any of its whitespace/slash spellings (</script >, </script/>, ...),
// which an exact-match replace of "</script>" misses; escaping every "<" closes all
// of those plus "<!--" and "<script". The escape parses back to "<", so runtime
// values read from the exposed object are unchanged.
obj = obj.replace(/</g, '\\u003c');
obj = obj.replace(/\u2028/g, '\\u2028'); // Line separator
obj = obj.replace(/\u2029/g, '\\u2029'); // Paragraph separator
}
Expand Down Expand Up @@ -222,7 +226,8 @@ function escape_js_string(str) {
.replace(/\0/g, '\\0') // Null character
.replace(/[\u0000-\u001F\u007F-\u009F]/g, function(ch) {
return '\\u' + ('0000' + ch.charCodeAt(0).toString(16)).slice(-4);
});
})
.replace(/</g, '\\u003c'); // "<" so a key cannot break out of the <script> block
}

exports = module.exports = function(app) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1115,7 +1115,7 @@
var noteTime = moment(notes[0].ts).format("D MMM, HH:mm");
var noteId = notes[0].app_id;
var app = countlyGlobal.apps[noteId] || {};
titleDom = "<div> <div class='note-header'><div class='note-title'>" + noteTime + "</div><div class='note-app' style='display:flex;line-height: 15px;'> <div class='icon' style='display:inline-block; border-radius:2px; width:15px; height:15px; margin-right: 5px; background: url(appimages/" + noteId + ".png) center center / cover no-repeat;'></div><span>" + app.name + "</span></div></div>" +
titleDom = "<div> <div class='note-header'><div class='note-title'>" + noteTime + "</div><div class='note-app' style='display:flex;line-height: 15px;'> <div class='icon' style='display:inline-block; border-radius:2px; width:15px; height:15px; margin-right: 5px; background: url(appimages/" + noteId + ".png) center center / cover no-repeat;'></div><span>" + countlyCommon.encodeHtml(app.name) + "</span></div></div>" +
"<div class='note-content'>" + notes[0].note + "</div>" +
"<div class='note-footer'> <span class='note-owner'>" + (notes[0].owner_name) + "</span> | <span class='note-type'>" + (jQuery.i18n.map["notes.note-" + notes[0].noteType] || notes[0].noteType) + "</span> </div>" +
"</div>";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2403,7 +2403,7 @@ var AppRouter = Backbone.Router.extend({

countlyCommon.setActiveApp(activeApp._id);
self.activeAppName = activeApp.name;
$('#active-app-name').html(activeApp.name);
$('#active-app-name').text(activeApp.name);
$('#active-app-name').attr('title', activeApp.name);
$("#active-app-icon").css("background-image", "url('" + countlyGlobal.cdn + "appimages/" + countlyCommon.ACTIVE_APP_ID + ".png')");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,13 @@
var ret = "<p>" + ((jQuery.i18n.map["systemlogs.action." + row.a]) ? jQuery.i18n.map["systemlogs.action." + row.a] : row.a) + "</p>";
if (typeof row.i === "object") {
if (typeof row.i.app_id !== "undefined" && countlyGlobal.apps[row.i.app_id]) {
ret += "<p title='" + row.i.app_id + "'>" + jQuery.i18n.map["systemlogs.for-app"] + ": " + countlyGlobal.apps[row.i.app_id].name + "</p>";
//this string is rendered with v-html, so every interpolated value must already be
//HTML-safe. Everything taken from "row" arrives through common.returnOutput, which
//escape_html_entities has already escaped, so it must NOT be escaped again here or
//the entities would show up literally. The app name is the exception: it comes from
//countlyGlobal, which is serialized into the dashboard's script island by
//express-expose and is never HTML-escaped, so it reaches us raw and is escaped here.
ret += "<p title='" + row.i.app_id + "'>" + jQuery.i18n.map["systemlogs.for-app"] + ": " + countlyCommon.encodeHtml(countlyGlobal.apps[row.i.app_id].name) + "</p>";
}
if (typeof row.i.appuser_id !== "undefined") {
ret += "<p title='" + row.i.appuser_id + "'>" + jQuery.i18n.map["systemlogs.for-appuser"] + ": " + row.i.appuser_id + "</p>";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,11 @@
saveButtonLabel: CV.i18n('common.yes'),
cancelButtonLabel: CV.i18n('common.cancel'),
title: CV.i18n('populator.environment-delete-warning-title'),
text: CV.i18n('populator.environment-delete-warning-description', this.filterByEnvironmentOptions.filter(x => x.value === this.environmentId)[0].label)
//this sentence is rendered with v-html because the localized string itself carries
//markup, so the environment name substituted into it has to be escaped here. The name
//was html-decoded when the dropdown options were built, which is what the dropdown
//needs, so the escaping the api applied no longer holds by this point.
text: CV.i18n('populator.environment-delete-warning-description', countlyCommon.encodeHtml(this.filterByEnvironmentOptions.filter(x => x.value === this.environmentId)[0].label))
};
},
calculateWidth: function(percentage) {
Expand Down
3 changes: 2 additions & 1 deletion plugins/populator/frontend/public/templates/populator.html
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ <h3 class="populator-stop-modal-wrapper__finished-confirm-header" data-test-id="
</cly-tabs>
<cly-confirm-dialog class="populator-wrapper__start-dialog" :show-close="false" @cancel="closeConfirmDialog" @confirm="submitConfirmDialog" :before-close="closeConfirmDialog" ref="deleteConfirmDialog" :visible.sync="dialog.showDialog" dialogType="success" :saveButtonLabel="dialog.saveButtonLabel" :cancelButtonLabel="dialog.cancelButtonLabel" :saveButtonVisibility="dialog.saveButtonVisibility" :title="dialog.title" >
<template slot-scope="scope">
<div v-html="dialog.text"></div>
<!-- dialog.text is a localized sentence with a template name substituted in, never markup -->
<div>{{dialog.text}}</div>
</template>
</cly-confirm-dialog>
<cly-populator-template-drawer ref="populatorTemplateDrawer" @refresh-table="refresh" @closeHandler="refreshTable" :titleDescription="titleDescription" :controls="drawers.populatorTemplate"></cly-populator-template-drawer>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <span>. 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: {
Expand Down Expand Up @@ -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();
},
Expand Down
132 changes: 132 additions & 0 deletions test/unit-tests/plugins.compliance-hub.actions-escaping.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
var should = require("should");
var fs = require("fs");
var path = require("path");
var vm = require("vm");

// The export/purge history datatable builds an HTML string in onReady and the template renders it
// with v-html (plugins/compliance-hub/frontend/public/templates/exportHistory.html). Everything
// interpolated into that string therefore has to be HTML-safe already.
//
// Two opposite mistakes are possible and this file guards both directions:
//
// 1. NOT escaping the app name. It is read from countlyGlobal, which express-expose serializes
// into the dashboard's inline script island. That serializer escapes for the JavaScript string
// context only and is deliberately value-preserving, so the value arrives raw. An app admin can
// set their own app's name, and a global admin's dashboard lists every app, so an unescaped name
// is a stored cross-user XSS.
// 2. Escaping the values that came from the API. Those already went through
// common.escape_html_entities in common.returnOutput, so escaping them again would render the
// entities literally in the UI.
var SRC = path.resolve(__dirname, "../../plugins/compliance-hub/frontend/public/javascripts/countly.models.js");

/**
* Load countly.models.js in a sandbox and hand back the onReady callbacks it registers,
* keyed by data-table name.
* @param {object} apps - the countlyGlobal.apps map the module should see
* @returns {object} map of resource name to its onReady function
*/
function loadResources(apps) {
var resources = {};
var noop = function() {};
// matches countlyCommon.encodeHtml, which is `div.innerText = x; return div.innerHTML`:
// the text-node serializer escapes &, < and > and leaves quotes alone.
var encodeHtml = function(html) {
return (html + "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
};
var sandbox = {
window: {},
countlyCommon: {
encodeHtml: encodeHtml,
formatTimeAgoText: function() {
return { text: "just now" };
},
getDescendantProp: noop,
API_PARTS: { data: { r: "/o" } },
ACTIVE_APP_ID: "5f1a2b3c4d5e6f0011223344",
periodObj: {},
getPeriodForAjax: function() {
return "30days";
}
},
CountlyHelpers: { createMetricModel: noop },
jQuery: { i18n: { map: { "systemlogs.for-app": "For app", "systemlogs.for-appuser": "For app user", "systemlogs.action.export": "Data exported" } } },
CV: {
i18n: function(k) {
return k;
}
},
countlyGlobal: { apps: apps },
countlyTaskManager: {},
countlyVue: {
vuex: {
ServerDataTable: function(name, cfg) {
resources[name] = cfg.onReady;
return { name: name };
},
Module: function() {
return {};
},
MutationsFor: noop,
ActionsFor: noop
}
}
};
sandbox.global = sandbox;
vm.createContext(sandbox);
vm.runInContext(fs.readFileSync(SRC, "utf8"), sandbox, { filename: SRC });
return resources;
}

describe("compliance-hub export history actions escaping", function() {
var APP_ID = "5f1a2b3c4d5e6f0011223344";

/**
* Run the export-history onReady over a single row.
* @param {string} appName - the app name countlyGlobal should carry
* @param {object} i - the row's "i" payload
* @returns {string} the built actions HTML
*/
function actionsFor(appName, i) {
var apps = {};
apps[APP_ID] = { name: appName };
var onReady = loadResources(apps).exportHistoryDataResource;
should.exist(onReady);
var rows = onReady({}, [{ a: "export", ts: 0, i: i || { app_id: APP_ID } }]);
return rows[0].actions;
}

it("escapes an app name that carries a tag", function(done) {
var actions = actionsFor('<img src=x onerror="alert(1)">');
actions.indexOf("<img").should.equal(-1);
actions.should.containEql("&lt;img src=x onerror=&quot;alert(1)&quot;&gt;".replace(/&quot;/g, '"'));
done();
});

it("escapes an app name that closes the surrounding tag", function(done) {
var actions = actionsFor("</p><script>alert(1)</script>");
actions.indexOf("<script").should.equal(-1);
actions.indexOf("</script>").should.equal(-1);
done();
});

it("leaves no raw angle bracket from the app name", function(done) {
var actions = actionsFor("<svg onload=alert(1)>");
// the only markup left must be the <p> wrappers this builder emits itself
actions.replace(/<\/?p[^>]*>/g, "").indexOf("<").should.equal(-1);
done();
});

it("keeps an ordinary app name readable", function(done) {
var actions = actionsFor("My Application");
actions.should.containEql("For app: My Application");
done();
});

it("does not double-escape values the API already escaped", function(done) {
// returnOutput turns ' into &#39;; escaping again would surface "&amp;#39;" in the UI
var actions = actionsFor("My Application", { app_id: APP_ID, appuser_id: "user&#39;s-id" });
actions.should.containEql("user&#39;s-id");
actions.indexOf("&amp;#39;").should.equal(-1);
done();
});
});
Loading