CVE-2019-14549: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: An issue was discovered in EspoCRM before 5.6.9. Stored XSS was executed inside the title and breadcrumb of a newly formed entity available to all the users. A malicious user can inject JavaScript in these values of an entity, thus stealing user cookies when someone visits the publicly accessible link.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 5.4
Published: 2019-08-05T19:15Z
Repository: https://github.com/espocrm/espocrm
Language: JavaScript
Files changed: client/res/templates/attachment/fields/name/detail.tpl, client/src/view-helper.js, client/src/views/attachment/fields/name.js, client/src/views/fields/wysiwyg.js, client/src/views/preferences/fields/dashboard-tab-list.js
Commit date: 2019-07-30 10:57:44 +0300
Commit message: xss fixes

Diff: diff --git a/client/res/templates/attachment/fields/name/detail.tpl b/client/res/templates/attachment/fields/name/detail.tpl
index 454f6eeba..ee4a129e4 100644
--- a/client/res/templates/attachment/fields/name/detail.tpl
+++ b/client/res/templates/attachment/fields/name/detail.tpl
@@ -1,2 +1,2 @@
 
-<span class="fas fa-paperclip small"></span> <a href="{{url}}">{{{value}}}</a>
+<span class="fas fa-paperclip small"></span> <a href="{{url}}">{{value}}</a>
diff --git a/client/src/view-helper.js b/client/src/view-helper.js
index be2724fb6..40b31acf7 100644
--- a/client/src/view-helper.js
+++ b/client/src/view-helper.js
@@ -319,9 +319,80 @@ define('view-helper', ['lib!client/lib/purify.min.js'], function () {
         },
 
         sanitizeHtml: function (text, options) {
             return DOMPurify.sanitize(text, options);
         },
+
+        moderateSanitizeHtml: function (value) {
+            value = value || '';
+            value = value.replace(/<[\/]{0,1}(base)[^><]*>/gi, '');
+            value = value.replace(/<[\/]{0,1}(object)[^><]*>/gi, '');
+            value = value.replace(/<[\/]{0,1}(embed)[^><]*>/gi, '');
+            value = value.replace(/<[\/]{0,1}(applet)[^><]*>/gi, '');
+            value = value.replace(/<[\/]{0,1}(iframe)[^><]*>/gi, '');
+            value = value.replace(/<[\/]{0,1}(script)[^><]*>/gi, '');
+            value = value.replace(/<[^><]*([^a-z]{1}on[a-z]+)=[^><]*>/gi, function (match) {
+                return match.replace(/[^a-z]{1}on[a-z]+=/gi, ' data-handler-stripped=');
+            });
+
+            value = this.stripEventHandlersInHtml(value);
+
+            value = value.replace(/href=" *javascript\:(.*?)"/gi, function(m, $1) {
+                return 'removed=""';
+            });
+            value = value.replace(/href=' *javascript\:(.*?)'/gi, function(m, $1) {
+                return 'removed=""';
+            });
+            value = value.replace(/src=" *javascript\:(.*?)"/gi, function(m, $1) {
+                return 'removed=""';
+            });
+            value = value.replace(/src=' *javascript\:(.*?)'/gi, function(m, $1) {
+                return 'removed=""';
+            });
+
+            return value;
+        },
+
+        stripEventHandlersInHtml: function (html) {
+            function stripHTML(){
+                html = html.slice(0, strip) + html.slice(j);
+                j = strip;
+                strip = false;
+            }
+            function isValidTagChar(str) {
+                return str.match(/[a-z?\\\/!]/i);
+            }
+            var strip = false;
+            var lastQuote = false;
+            for (var i = 0; i<html.length; i++){
+                if (html[i] === "<" && html[i+1] && isValidTagChar(html[i+1])) {
+                    i++;
+                    for (var j = i; j<html.length; j++){
+                        if (!lastQuote && html[j] === ">"){
+                            if (strip) {
+                                stripHTML();
+                            }
+                            i = j;
+                            break;
+                        }
+                        if (lastQuote === html[j]){
+                            lastQuote = false;
+                            continue;
+                        }
+                        if (!lastQuote && html[j-1] === "=" && (html[j] === "'" || html[j] === '"')){
+                            lastQuote = html[j];
+                        }
+                        if (!lastQuote && html[j-2] === " " && html[j-1] === "o" && html[j] === "n"){
+                            strip = j-2;
+                        }
+                        if (strip && html[j] === " " && !lastQuote){
+                            stripHTML();
+                        }
+                    }
+                }
+            }
+            return html;
+        },
     });
 
     return ViewHelper;
 });
diff --git a/client/src/views/attachment/fields/name.js b/client/src/views/attachment/fields/name.js
index 0f0cb268f..ee012ede0 100644
--- a/client/src/views/attachment/fields/name.js
+++ b/client/src/views/attachment/fields/name.js
@@ -24,11 +24,11 @@
  *
  * In accordance with Section 7(b) of the GNU General Public License version 3,
  * these Appropriate Legal Notices must retain the display of tтhe "EspoCRM" word.
  ************************************************************************/
 
-Espo.define('views/attachment/fields/name', 'views/fields/varchar', function (Dep) {
+define('views/attachment/fields/name', 'views/fields/varchar', function (Dep) {
 
     return Dep.extend({
 
         detailTemplate: 'attachment/fields/name/detail',
 
diff --git a/client/src/views/fields/wysiwyg.js b/client/src/views/fields/wysiwyg.js
index a7b2c02d4..b96086eb1 100644
--- a/client/src/views/fields/wysiwyg.js
+++ b/client/src/views/fields/wysiwyg.js
@@ -163,34 +163,11 @@ Espo.define('views/fields/wysiwyg', ['views/fields/text', 'lib!Summernote'], fun
             return value || '';
         },
 
 
         sanitizeHtmlLight: function (value) {
-            value = value || '';
-            value = value.replace(/<[\/]{0,1}(base)[^><]*>/gi, '');
-            value = value.replace(/<[\/]{0,1}(object)[^><]*>/gi, '');
-            value = value.replace(/<[\/]{0,1}(embed)[^><]*>/gi, '');
-            value = value.replace(/<[\/]{0,1}(applet)[^><]*>/gi, '');
-            value = value.replace(/<[\/]{0,1}(iframe)[^><]*>/gi, '');
-            value = value.replace(/<[\/]{0,1}(script)[^><]*>/gi, '');
-            value = value.replace(/<[^><]*([^a-z]{1}on[a-z]+)=[^><]*>/gi, function (match) {
-                return match.replace(/[^a-z]{1}on[a-z]+=/gi, ' data-handler-stripped=');
-            });
-
-            value = value.replace(/href=" *javascript\:(.*?)"/gi, function(m, $1) {
-                return 'removed=""';
-            });
-            value = value.replace(/href=' *javascript\:(.*?)'/gi, function(m, $1) {
-                return 'removed=""';
-            });
-            value = value.replace(/src=" *javascript\:(.*?)"/gi, function(m, $1) {
-                return 'removed=""';
-            });
-            value = value.replace(/src=' *javascript\:(.*?)'/gi, function(m, $1) {
-                return 'removed=""';
-            });
-            return value;
+           return this.getHelper().moderateSanitizeHtml(value);
         },
 
         getValueForEdit: function () {
             var value = this.model.get(this.name) || '';
             return this.sanitizeHtmlLight(value);
diff --git a/client/src/views/preferences/fields/dashboard-tab-list.js b/client/src/views/preferences/fields/dashboard-tab-list.js
index b8a855f2c..c043154ef 100644
--- a/client/src/views/preferences/fields/dashboard-tab-list.js
+++ b/client/src/views/preferences/fields/dashboard-tab-list.js
@@ -24,11 +24,11 @@
  *
  * In accordance with Section 7(b) of the GNU General Public License version 3,
  * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
  ************************************************************************/
 
-Espo.define('views/preferences/fields/dashboard-tab-list', 'views/fields/array', function (Dep) {
+define('views/preferences/fields/dashboard-tab-list', 'views/fields/array', function (Dep) {
 
     return Dep.extend({
 
         setup: function () {
             Dep.prototype.setup.call(this);
@@ -37,32 +37,34 @@ Espo.define('views/preferences/fields/dashboard-tab-list', 'views/fields/array',
             var list = this.model.get(this.name) || [];
             list.forEach(function (value) {
                 this.translatedOptions[value] = value;
             }, this);
         },
-
         getItemHtml: function (value) {
-            var translatedValue = this.translatedOptions[value] || value;
+            value = value.toString();
+            var valueSanitized = this.escapeValue(value);
+            var translatedValue = this.escapeValue(this.translatedOptions[value] || value);
 
             var html = '' +
-            '<div class="list-group-item link-with-role form-inline" data-value="' + value + '">' +
+            '<div class="list-group-item link-with-role form-inline" data-value="' + valueSanitized + '">' +
                 '<div class="pull-left" style="width: 92%; display: inline-block;">' +
-                    '<input data-name="translatedValue" data-value="' + value + '" class="role form-control input-sm" value="'+translatedValue+'">' +
+                    '<input data-name="translatedValue" data-value="' + valueSanitized + '" class="role form-control input-sm" value="'+translatedValue+'">' +
                 '</div>' +
                 '<div style="width: 8%; display: inline-block; vertical-align: top;">' +
-                    '<a href="javascript:" class="pull-right" data-value="' + value + '" data-action="removeValue"><span class="fas fa-times"></a>' +
+                    '<a href="javascript:" class="pull-right" data-value="' + valueSanitized + '" data-action="removeValue"><span class="fas fa-times"></a>' +
                 '</div><br style="clear: both;" />' +
             '</div>';
 
             return html;
         },
 
         fetch: function () {
             var data = Dep.prototype.fetch.call(this);
             data.translatedOptions = {};
             (data[this.name] || []).forEach(function (value) {
-                data.translatedOptions[value] = this.$el.find('input[data-name="translatedValue"][data-value="'+value+'"]').val() || value;
+                var valueInternal = value.replace(/"/g, '\\"');
+                data.translatedOptions[value] = this.$el.find('input[data-name="translatedValue"][data-value="'+valueInternal+'"]').val() || value;
             }, this);
 
             return data;
         }
 

Vulnerable code: <span class="fas fa-paperclip small"></span> <a href="{{url}}">{{{value}}}</a>
Espo.define('views/attachment/fields/name', 'views/fields/varchar', function (Dep) {
            value = value || '';
            value = value.replace(/<[\/]{0,1}(base)[^><]*>/gi, '');
            value = value.replace(/<[\/]{0,1}(object)[^><]*>/gi, '');
            value = value.replace(/<[\/]{0,1}(embed)[^><]*>/gi, '');
            value = value.replace(/<[\/]{0,1}(applet)[^><]*>/gi, '');
            value = value.replace(/<[\/]{0,1}(iframe)[^><]*>/gi, '');
            value = value.replace(/<[\/]{0,1}(script)[^><]*>/gi, '');
            value = value.replace(/<[^><]*([^a-z]{1}on[a-z]+)=[^><]*>/gi, function (match) {
                return match.replace(/[^a-z]{1}on[a-z]+=/gi, ' data-handler-stripped=');
            });

            value = value.replace(/href=" *javascript\:(.*?)"/gi, function(m, $1) {
                return 'removed=""';
            });
            value = value.replace(/href=' *javascript\:(.*?)'/gi, function(m, $1) {
                return 'removed=""';
            });
            value = value.replace(/src=" *javascript\:(.*?)"/gi, function(m, $1) {
                return 'removed=""';
            });
            value = value.replace(/src=' *javascript\:(.*?)'/gi, function(m, $1) {
                return 'removed=""';
            });
            return value;
Espo.define('views/preferences/fields/dashboard-tab-list', 'views/fields/array', function (Dep) {

            var translatedValue = this.translatedOptions[value] || value;
            '<div class="list-group-item link-with-role form-inline" data-value="' + value + '">' +
                    '<input data-name="translatedValue" data-value="' + value + '" class="role form-control input-sm" value="'+translatedValue+'">' +
                    '<a href="javascript:" class="pull-right" data-value="' + value + '" data-action="removeValue"><span class="fas fa-times"></a>' +
                data.translatedOptions[value] = this.$el.find('input[data-name="translatedValue"][data-value="'+value+'"]').val() || value;
Fixed code: <span class="fas fa-paperclip small"></span> <a href="{{url}}">{{value}}</a>

        moderateSanitizeHtml: function (value) {
            value = value || '';
            value = value.replace(/<[\/]{0,1}(base)[^><]*>/gi, '');
            value = value.replace(/<[\/]{0,1}(object)[^><]*>/gi, '');
            value = value.replace(/<[\/]{0,1}(embed)[^><]*>/gi, '');
            value = value.replace(/<[\/]{0,1}(applet)[^><]*>/gi, '');
            value = value.replace(/<[\/]{0,1}(iframe)[^><]*>/gi, '');
            value = value.replace(/<[\/]{0,1}(script)[^><]*>/gi, '');
            value = value.replace(/<[^><]*([^a-z]{1}on[a-z]+)=[^><]*>/gi, function (match) {
                return match.replace(/[^a-z]{1}on[a-z]+=/gi, ' data-handler-stripped=');
            });

            value = this.stripEventHandlersInHtml(value);

            value = value.replace(/href=" *javascript\:(.*?)"/gi, function(m, $1) {
                return 'removed=""';
            });
            value = value.replace(/href=' *javascript\:(.*?)'/gi, function(m, $1) {
                return 'removed=""';
            });
            value = value.replace(/src=" *javascript\:(.*?)"/gi, function(m, $1) {
                return 'removed=""';
            });
            value = value.replace(/src=' *javascript\:(.*?)'/gi, function(m, $1) {
                return 'removed=""';
            });

            return value;
        },

        stripEventHandlersInHtml: function (html) {
            function stripHTML(){
                html = html.slice(0, strip) + html.slice(j);
                j = strip;
                strip = false;
            }
            function isValidTagChar(str) {
                return str.match(/[a-z?\\\/!]/i);
            }
            var strip = false;
            var lastQuote = false;
            for (var i = 0; i<html.length; i++){
                if (html[i] === "<" && html[i+1] && isValidTagChar(html[i+1])) {
                    i++;
                    for (var j = i; j<html.length; j++){
                        if (!lastQuote && html[j] === ">"){
                            if (strip) {
                                stripHTML();
                            }
                            i = j;
                            break;
                        }
                        if (lastQuote === html[j]){
                            lastQuote = false;
                            continue;
                        }
                        if (!lastQuote && html[j-1] === "=" && (html[j] === "'" || html[j] === '"')){
                            lastQuote = html[j];
                        }
                        if (!lastQuote && html[j-2] === " " && html[j-1] === "o" && html[j] === "n"){
                            strip = j-2;
                        }
                        if (strip && html[j] === " " && !lastQuote){
                            stripHTML();
                        }
                    }
                }
            }
            return html;
        },
define('views/attachment/fields/name', 'views/fields/varchar', function (Dep) {
           return this.getHelper().moderateSanitizeHtml(value);
define('views/preferences/fields/dashboard-tab-list', 'views/fields/array', function (Dep) {
            value = value.toString();
            var valueSanitized = this.escapeValue(value);
            var translatedValue = this.escapeValue(this.translatedOptions[value] || value);
            '<div class="list-group-item link-with-role form-inline" data-value="' + valueSanitized + '">' +
                    '<input data-name="translatedValue" data-value="' + valueSanitized + '" class="role form-control input-sm" value="'+translatedValue+'">' +
                    '<a href="javascript:" class="pull-right" data-value="' + valueSanitized + '" data-action="removeValue"><span class="fas fa-times"></a>' +
                var valueInternal = value.replace(/"/g, '\\"');
                data.translatedOptions[value] = this.$el.find('input[data-name="translatedValue"][data-value="'+valueInternal+'"]').val() || value;