CVE-2020-4061: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: In October from version 1.0.319 and before version 1.0.467, pasting content copied from malicious websites into the Froala richeditor could result in a successful self-XSS attack. This has been fixed in 1.0.467.
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: 2020-07-02T17:15Z
Repository: https://github.com/octobercms/october
Language: JavaScript
Files changed: modules/backend/formwidgets/mediafinder/assets/js/mediafinder.js, modules/backend/formwidgets/richeditor/assets/js/build-plugins-min.js, modules/backend/formwidgets/richeditor/assets/js/richeditor.js, modules/system/assets/js/framework-min.js, modules/system/assets/js/framework.combined-min.js, modules/system/assets/js/framework.js
Commit date: 2020-06-18 11:03:48 -0600
Commit message: Improve Froala sanitization of pasted content.

Diff: diff --git a/modules/backend/formwidgets/mediafinder/assets/js/mediafinder.js b/modules/backend/formwidgets/mediafinder/assets/js/mediafinder.js
index eb6cc9bbf9..6ed1889293 100644
--- a/modules/backend/formwidgets/mediafinder/assets/js/mediafinder.js
+++ b/modules/backend/formwidgets/mediafinder/assets/js/mediafinder.js
@@ -4,11 +4,11 @@
  * Data attributes:
  * - data-control="mediafinder" - enables the plugin on an element
  * - data-option="value" - an option with a value
  *
  * JavaScript API:
- * $('a#someElement').recordFinder({ option: 'value' })
+ * $('a#someElement').mediaFinder({ option: 'value' })
  *
  * Dependences:
  * - Some other plugin (filename.js)
  */
 
@@ -69,11 +69,11 @@
         this.$el.removeData('oc.mediaFinder')
 
         this.$findValue = null
         this.$el = null
 
-        // In some cases options could contain callbacks, 
+        // In some cases options could contain callbacks,
         // so it's better to clean them up too.
         this.options = null
 
         BaseProto.dispose.call(this)
     }
diff --git a/modules/backend/formwidgets/richeditor/assets/js/build-plugins-min.js b/modules/backend/formwidgets/richeditor/assets/js/build-plugins-min.js
index c9c6aa38ee..95c153829b 100644
--- a/modules/backend/formwidgets/richeditor/assets/js/build-plugins-min.js
+++ b/modules/backend/formwidgets/richeditor/assets/js/build-plugins-min.js
@@ -192,10 +192,11 @@ if(!this.options.useMediaManager){delete $.FroalaEditor.PLUGINS.mediaManager}
 $.FroalaEditor.ICON_TEMPLATES={font_awesome:'<i class="icon-[NAME]"></i>',text:'<span style="text-align: center;">[NAME]</span>',image:'<img src=[SRC] alt=[ALT] />'}
 this.$textarea.on('froalaEditor.initialized',this.proxy(this.build))
 this.$textarea.on('froalaEditor.contentChanged',this.proxy(this.onChange))
 this.$textarea.on('froalaEditor.html.get',this.proxy(this.onSyncContent))
 this.$textarea.on('froalaEditor.html.set',this.proxy(this.onSetContent))
+this.$textarea.on('froalaEditor.paste.beforeCleanup',this.proxy(this.beforeCleanupPaste))
 this.$form.on('oc.beforeRequest',this.proxy(this.onFormBeforeRequest))
 this.$textarea.froalaEditor(froalaOptions)
 this.editor=this.$textarea.data('froala.editor')
 if(this.options.readOnly){this.editor.edit.off()}
 this.$el.on('keydown','.fr-view figure',this.proxy(this.onFigureKeydown))}
@@ -211,10 +212,11 @@ BaseProto.dispose.call(this)}
 RichEditor.prototype.unregisterHandlers=function(){this.$el.off('keydown','.fr-view figure',this.proxy(this.onFigureKeydown))
 this.$textarea.off('froalaEditor.initialized',this.proxy(this.build))
 this.$textarea.off('froalaEditor.contentChanged',this.proxy(this.onChange))
 this.$textarea.off('froalaEditor.html.get',this.proxy(this.onSyncContent))
 this.$textarea.off('froalaEditor.html.set',this.proxy(this.onSetContent))
+this.$textarea.off('froalaEditor.paste.beforeCleanup',this.proxy(this.beforeCleanupPaste))
 this.$form.off('oc.beforeRequest',this.proxy(this.onFormBeforeRequest))
 $(window).off('resize',this.proxy(this.updateLayout))
 $(window).off('oc.updateUi',this.proxy(this.updateLayout))
 this.$el.off('dispose-control',this.proxy(this.dispose))}
 RichEditor.prototype.build=function(event,editor){this.updateLayout()
@@ -241,10 +243,11 @@ this.editor.selection.restore()}
 RichEditor.prototype.insertElement=function($el){this.insertHtml($('<div />').append($el.clone()).remove().html())}
 RichEditor.prototype.insertUiBlock=function($node){this.$textarea.froalaEditor('figures.insert',$node)}
 RichEditor.prototype.insertVideo=function(url,title){this.$textarea.froalaEditor('figures.insertVideo',url,title)}
 RichEditor.prototype.insertAudio=function(url,title){this.$textarea.froalaEditor('figures.insertAudio',url,title)}
 RichEditor.prototype.onSetContent=function(ev,editor){this.$textarea.trigger('setContent.oc.richeditor',[this])}
+RichEditor.prototype.beforeCleanupPaste=function(ev,editor,clipboard_html){return ocSanitize(clipboard_html)}
 RichEditor.prototype.onSyncContent=function(ev,editor,html){if(editor.codeBeautifier){html=editor.codeBeautifier.run(html,editor.opts.codeBeautifierOptions)}
 var container={html:html}
 this.$textarea.trigger('syncContent.oc.richeditor',[this,container])
 return container.html}
 RichEditor.prototype.onFocus=function(){this.$el.addClass('editor-focus')}
diff --git a/modules/backend/formwidgets/richeditor/assets/js/richeditor.js b/modules/backend/formwidgets/richeditor/assets/js/richeditor.js
index a7f3b91e4c..0fcb1227cc 100755
--- a/modules/backend/formwidgets/richeditor/assets/js/richeditor.js
+++ b/modules/backend/formwidgets/richeditor/assets/js/richeditor.js
@@ -207,10 +207,11 @@
 
         this.$textarea.on('froalaEditor.initialized', this.proxy(this.build))
         this.$textarea.on('froalaEditor.contentChanged', this.proxy(this.onChange))
         this.$textarea.on('froalaEditor.html.get', this.proxy(this.onSyncContent))
         this.$textarea.on('froalaEditor.html.set', this.proxy(this.onSetContent))
+        this.$textarea.on('froalaEditor.paste.beforeCleanup', this.proxy(this.beforeCleanupPaste))
         this.$form.on('oc.beforeRequest', this.proxy(this.onFormBeforeRequest))
 
         this.$textarea.froalaEditor(froalaOptions)
 
         this.editor = this.$textarea.data('froala.editor')
@@ -243,10 +244,11 @@
 
         this.$textarea.off('froalaEditor.initialized', this.proxy(this.build))
         this.$textarea.off('froalaEditor.contentChanged', this.proxy(this.onChange))
         this.$textarea.off('froalaEditor.html.get', this.proxy(this.onSyncContent))
         this.$textarea.off('froalaEditor.html.set', this.proxy(this.onSetContent))
+        this.$textarea.off('froalaEditor.paste.beforeCleanup', this.proxy(this.beforeCleanupPaste))
         this.$form.off('oc.beforeRequest', this.proxy(this.onFormBeforeRequest))
 
         $(window).off('resize', this.proxy(this.updateLayout))
         $(window).off('oc.updateUi', this.proxy(this.updateLayout))
         this.$el.off('dispose-control', this.proxy(this.dispose))
@@ -342,10 +344,14 @@
 
     RichEditor.prototype.onSetContent = function(ev, editor) {
         this.$textarea.trigger('setContent.oc.richeditor', [this])
     }
 
+    RichEditor.prototype.beforeCleanupPaste = function (ev, editor, clipboard_html) {
+        return ocSanitize(clipboard_html)
+    }
+
     RichEditor.prototype.onSyncContent = function(ev, editor, html) {
         // Beautify HTML.
         if (editor.codeBeautifier) {
             html = editor.codeBeautifier.run(html, editor.opts.codeBeautifierOptions)
         }
diff --git a/modules/system/assets/js/framework-min.js b/modules/system/assets/js/framework-min.js
index d5ff8234bf..a75e431434 100644
--- a/modules/system/assets/js/framework-min.js
+++ b/modules/system/assets/js/framework-min.js
@@ -183,6 +183,8 @@ result+="}";return result;}else if(last!==i&&result!=="{"){result+=",";type="nee
 throw new Error("Broken JSON object near "+result);}
 if(str[0]==="["){var result="[";var type="needBody";for(var i=1;i<str.length;i++){if(" "===str[i]||"\n"===str[i]||"\t"===str[i]){continue;}else if(type==="needBody"){if(str[i]===","){result+="null,";continue;}
 if(str[i]==="]"&&i===str.length-1){if(result[result.length-1]===",")result=result.substr(0,result.length-1);result+="]";return result;}
 var body=getBody(str,i);i=i+body.originLength-1;result+=parse(body.body);type="afterBody";}else if(type==="afterBody"){if(str[i]===","){result+=",";type="needBody";while(str[i+1]===","||isBlankChar(str[i+1])){if(str[i+1]===",")result+="null,";i++;}}else if(str[i]==="]"&&i===str.length-1){result+="]";return result;}}}
 throw new Error("Broken JSON array near "+result);}}
-window.ocJSON=function(json){var jsonString=parse(json);return JSON.parse(jsonString);};}(window);
\ No newline at end of file
+window.ocJSON=function(json){var jsonString=parse(json);return JSON.parse(jsonString);};}(window);+function(window){"use strict";function trimAttributes(node){$.each(node.attributes,function(){var attrName=this.name;var attrValue=this.value;if(attrName.indexOf('on')==0||attrValue.indexOf('javascript:')==0){$(node).removeAttr(attrName);}});}
+function sanitize(html){var output=$($.parseHTML('<div>'+html+'</div>',null,false));output.find('*').each(function(){trimAttributes(this);});return output.html();}
+window.ocSanitize=function(html){return sanitize(html)};}(window);
\ No newline at end of file
diff --git a/modules/system/assets/js/framework.combined-min.js b/modules/system/assets/js/framework.combined-min.js
index 52c68980d1..382a869ecd 100644
--- a/modules/system/assets/js/framework.combined-min.js
+++ b/modules/system/assets/js/framework.combined-min.js
@@ -183,11 +183,13 @@ result+="}";return result;}else if(last!==i&&result!=="{"){result+=",";type="nee
 throw new Error("Broken JSON object near "+result);}
 if(str[0]==="["){var result="[";var type="needBody";for(var i=1;i<str.length;i++){if(" "===str[i]||"\n"===str[i]||"\t"===str[i]){continue;}else if(type==="needBody"){if(str[i]===","){result+="null,";continue;}
 if(str[i]==="]"&&i===str.length-1){if(result[result.length-1]===",")result=result.substr(0,result.length-1);result+="]";return result;}
 var body=getBody(str,i);i=i+body.originLength-1;result+=parse(body.body);type="afterBody";}else if(type==="afterBody"){if(str[i]===","){result+=",";type="needBody";while(str[i+1]===","||isBlankChar(str[i+1])){if(str[i+1]===",")result+="null,";i++;}}else if(str[i]==="]"&&i===str.length-1){result+="]";return result;}}}
 throw new Error("Broken JSON array near "+result);}}
-window.ocJSON=function(json){var jsonString=parse(json);return JSON.parse(jsonString);};}(window);+function($){"use strict";if($.oc===undefined)
+window.ocJSON=function(json){var jsonString=parse(json);return JSON.parse(jsonString);};}(window);+function(window){"use strict";function trimAttributes(node){$.each(node.attributes,function(){var attrName=this.name;var attrValue=this.value;if(attrName.indexOf('on')==0||attrValue.indexOf('javascript:')==0){$(node).removeAttr(attrName);}});}
+function sanitize(html){var output=$($.parseHTML('<div>'+html+'</div>',null,false));output.find('*').each(function(){trimAttributes(this);});return output.html();}
+window.ocSanitize=function(html){return sanitize(html)};}(window);+function($){"use strict";if($.oc===undefined)
 $.oc={}
 var LOADER_CLASS='oc-loading';$(document).on('ajaxSetup','[data-request][data-request-flash]',function(event,context){context.options.handleErrorMessage=function(message){$.oc.flashMsg({text:message,class:'error'})}
 context.options.handleFlashMessage=function(message,type){$.oc.flashMsg({text:message,class:type})}})
 $(document).on('ajaxValidation','[data-request][data-request-validate]',function(event,context,errorMsg,fields){var $this=$(this).closest('form'),$container=$('[data-validate-error]',$this),messages=[],$field
 $.each(fields,function(fieldName,fieldMessages){$field=$('[data-validate-for="'+fieldName+'"]',$this)
diff --git a/modules/system/assets/js/framework.js b/modules/system/assets/js/framework.js
index 6a6723ec7d..c97a1554cb 100644
--- a/modules/system/assets/js/framework.js
+++ b/modules/system/assets/js/framework.js
@@ -905,5 +905,73 @@ if (window.jQuery.request !== undefined) {
         var jsonString = parse(json);
         return JSON.parse(jsonString);
     };
 
 }(window);
+
+/*
+ * October CMS jQuery HTML Sanitizer
+ * @see https://gist.github.com/ufologist/5a0da51b2b9ef1b861c30254172ac3c9
+ */
++function(window) { "use strict";
+
+    function trimAttributes(node) {
+        $.each(node.attributes, function() {
+            var attrName = this.name;
+            var attrValue = this.value;
+
+            /*
+             * remove attributes where the names start with "on" (for example: onload, onerror...)
+             * remove attributes where the value starts with the "javascript:" pseudo protocol (for example href="javascript:alert(1)")
+             */
+            if (attrName.indexOf('on') == 0 || attrValue.indexOf('javascript:') == 0) {
+                $(node).removeAttr(attrName);
+            }
+        });
+    }
+
+    function sanitize(html) {
+        /*
+         * [jQuery.parseHTML(data [, context ] [, keepScripts ])](http://api.jquery.com/jQuery.parseHTML/) added: 1.8
+         * Parses a string into an array of DOM nodes.
+         *
+         * By default, the context is the current document if not specified or given as null or undefined. If the HTML was to be used
+         * in another document such as an iframe, that frame's document could be used.
+         *
+         * As of 3.0 the default behavior is changed.
+         *
+         * If the context is not specified or given as null or undefined, a new document is used.
+         * This can potentially improve security because inline events will not execute when the HTML is parsed. Once the parsed HTML
+         * is injected into a document it does execute, but this gives tools a chance to traverse the created DOM and remove anything
+         * deemed unsafe. This improvement does not apply to internal uses of jQuery.parseHTML as they usually pass in the current
+         * document. Therefore, a statement like $( "#log" ).append( $( htmlString ) ) is still subject to the injection of malicious code.
+         *
+         * without context do not execute script
+         * $.parseHTML('<div><img src=1 onerror=alert(1)></div>');
+         * $.parseHTML('<div><img src=1 onerror=alert(2)></div>', null);
+         *
+         * with context document execute script!
+         * $.parseHTML('<div><img src=1 onerror=alert(3)></div>', document);
+         *
+         * Most jQuery APIs that accept HTML strings will run scripts that are included in the HTML. jQuery.parseHTML does not run scripts
+         * in the parsed HTML unless keepScripts is explicitly true. However, it is still possible in most environments to execute scripts
+         * indirectly, for example via the <img onerror> attribute.
+         *
+         * will return []
+         * $.parseHTML('<script>alert(1)<\/script>', null, false);
+         *
+         * will return [script DOM element]
+         * $.parseHTML('<script>alert(1)<\/script>', null, true);
+         */
+        var output = $($.parseHTML('<div>' + html + '</div>', null, false));
+        output.find('*').each(function() {
+            trimAttributes(this);
+        });
+        return output.html();
+    }
+
+    // Global function
+    window.ocSanitize = function(html) {
+        return sanitize(html)
+    };
+
+}(window);
\ No newline at end of file

Vulnerable code:  * $('a#someElement').recordFinder({ option: 'value' })
        // In some cases options could contain callbacks, 
window.ocJSON=function(json){var jsonString=parse(json);return JSON.parse(jsonString);};}(window);
window.ocJSON=function(json){var jsonString=parse(json);return JSON.parse(jsonString);};}(window);+function($){"use strict";if($.oc===undefined)
Fixed code:  * $('a#someElement').mediaFinder({ option: 'value' })
        // In some cases options could contain callbacks,
this.$textarea.on('froalaEditor.paste.beforeCleanup',this.proxy(this.beforeCleanupPaste))
this.$textarea.off('froalaEditor.paste.beforeCleanup',this.proxy(this.beforeCleanupPaste))
RichEditor.prototype.beforeCleanupPaste=function(ev,editor,clipboard_html){return ocSanitize(clipboard_html)}
        this.$textarea.on('froalaEditor.paste.beforeCleanup', this.proxy(this.beforeCleanupPaste))
        this.$textarea.off('froalaEditor.paste.beforeCleanup', this.proxy(this.beforeCleanupPaste))
    RichEditor.prototype.beforeCleanupPaste = function (ev, editor, clipboard_html) {
        return ocSanitize(clipboard_html)
    }

window.ocJSON=function(json){var jsonString=parse(json);return JSON.parse(jsonString);};}(window);+function(window){"use strict";function trimAttributes(node){$.each(node.attributes,function(){var attrName=this.name;var attrValue=this.value;if(attrName.indexOf('on')==0||attrValue.indexOf('javascript:')==0){$(node).removeAttr(attrName);}});}
function sanitize(html){var output=$($.parseHTML('<div>'+html+'</div>',null,false));output.find('*').each(function(){trimAttributes(this);});return output.html();}
window.ocSanitize=function(html){return sanitize(html)};}(window);
window.ocJSON=function(json){var jsonString=parse(json);return JSON.parse(jsonString);};}(window);+function(window){"use strict";function trimAttributes(node){$.each(node.attributes,function(){var attrName=this.name;var attrValue=this.value;if(attrName.indexOf('on')==0||attrValue.indexOf('javascript:')==0){$(node).removeAttr(attrName);}});}
function sanitize(html){var output=$($.parseHTML('<div>'+html+'</div>',null,false));output.find('*').each(function(){trimAttributes(this);});return output.html();}
window.ocSanitize=function(html){return sanitize(html)};}(window);+function($){"use strict";if($.oc===undefined)

/*
 * October CMS jQuery HTML Sanitizer
 * @see https://gist.github.com/ufologist/5a0da51b2b9ef1b861c30254172ac3c9
 */
+function(window) { "use strict";

    function trimAttributes(node) {
        $.each(node.attributes, function() {
            var attrName = this.name;
            var attrValue = this.value;

            /*
             * remove attributes where the names start with "on" (for example: onload, onerror...)
             * remove attributes where the value starts with the "javascript:" pseudo protocol (for example href="javascript:alert(1)")
             */
            if (attrName.indexOf('on') == 0 || attrValue.indexOf('javascript:') == 0) {
                $(node).removeAttr(attrName);
            }
        });
    }

    function sanitize(html) {
        /*
         * [jQuery.parseHTML(data [, context ] [, keepScripts ])](http://api.jquery.com/jQuery.parseHTML/) added: 1.8
         * Parses a string into an array of DOM nodes.
         *
         * By default, the context is the current document if not specified or given as null or undefined. If the HTML was to be used
         * in another document such as an iframe, that frame's document could be used.
         *
         * As of 3.0 the default behavior is changed.
         *
         * If the context is not specified or given as null or undefined, a new document is used.
         * This can potentially improve security because inline events will not execute when the HTML is parsed. Once the parsed HTML
         * is injected into a document it does execute, but this gives tools a chance to traverse the created DOM and remove anything
         * deemed unsafe. This improvement does not apply to internal uses of jQuery.parseHTML as they usually pass in the current
         * document. Therefore, a statement like $( "#log" ).append( $( htmlString ) ) is still subject to the injection of malicious code.
         *
         * without context do not execute script
         * $.parseHTML('<div><img src=1 onerror=alert(1)></div>');
         * $.parseHTML('<div><img src=1 onerror=alert(2)></div>', null);
         *
         * with context document execute script!
         * $.parseHTML('<div><img src=1 onerror=alert(3)></div>', document);
         *
         * Most jQuery APIs that accept HTML strings will run scripts that are included in the HTML. jQuery.parseHTML does not run scripts
         * in the parsed HTML unless keepScripts is explicitly true. However, it is still possible in most environments to execute scripts
         * indirectly, for example via the <img onerror> attribute.
         *
         * will return []
         * $.parseHTML('<script>alert(1)<\/script>', null, false);
         *
         * will return [script DOM element]
         * $.parseHTML('<script>alert(1)<\/script>', null, true);
         */
        var output = $($.parseHTML('<div>' + html + '</div>', null, false));
        output.find('*').each(function() {
            trimAttributes(this);
        });
        return output.html();
    }

    // Global function
    window.ocSanitize = function(html) {
        return sanitize(html)
    };

}(window);