CVE-2019-14546: 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 on the Preference page as well as while sending an email when a malicious payload was inserted inside the Email Signature in the Preference page. The attacker could insert malicious JavaScript inside his email signature, which fires when the victim replies or forwards the mail, thus helping him steal victims' cookies (hence compromising their accounts).
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;