Vulnerability: Cross-site scripting in bootstrap-select bootstrap-select before 1.13.6 allows Cross-Site Scripting (XSS). It does not escape title values in OPTION elements. This may allow attackers to execute arbitrary JavaScript in a victim's browser.
Vulnerability ID: GHSA-7c82-mp33-r854
CWE: CWE-79
Severity score: 6.1
Project: https://github.com/snapappointments/bootstrap-select
Published: 2021-05-07
File: js/bootstrap-select.js
Commit message: add sanitize option for data-content (#2199)
Commit author: Casey Holzer

Vulnerable version (before fix):
 (function ($) {   'use strict';    // Polyfill for browsers with no classList support   // Remove in v2   if (!('classList' in document.createElement('_'))) {     dropdownAlignRight: false,     windowPadding: 0,     virtualScroll: 600,     display: false   };    if (version.major === '4') {                 emptyMenu = menuInner.firstChild.cloneNode(false),                 marginTop,                 marginBottom,                 elements = isVirtual === true ? that.selectpicker.view.visibleElements : that.selectpicker.current.elements;              // replace the existing UL with an empty one - this is faster than $.empty()             menuInner.replaceChild(emptyMenu, menuInner.firstChild);              for (var i = 0, visibleElementsLen = elements.length; i < visibleElementsLen; i++) {               menuFragment.appendChild(elements[i]);             }              if (isVirtual === true) {           multipleSeparator = document.createTextNode(this.options.multipleSeparator),           titleFragment = elementTemplates.fragment.cloneNode(false),           showCount,           countMax;        this.togglePlaceholder();                  titleOptions.text = option.title;               } else if (thisData.content && that.options.showContent) {                 titleOptions.optionContent = thisData.content.toString();               } else {                 if (that.options.showIcon) {                   titleOptions.optionIcon = thisData.icon;       // strip all HTML tags and trim the result, then unescape any escaped tags       this.$button[0].title = titleFragment.textContent.replace(/<[^>]*>?/g, '').trim();        buttonInner.innerHTML = '';       buttonInner.appendChild(titleFragment);              options = typeof _option == 'object' && _option;          if (!data) {           var config = $.extend({}, Selectpicker.DEFAULTS, $.fn.selectpicker.defaults || {}, $this.data(), options);           config.template = $.extend({}, Selectpicker.DEFAULTS.template, ($.fn.selectpicker.defaults ? $.fn.selectpicker.defaults.template : {}), $this.data().template, options.template);           $this.data('selectpicker', (data = new Selectpicker(this, config)));         } else if (options) {           for (var i in options) {

Fixed version (after fix):
 (function ($) {   'use strict';    var DISALLOWED_ATTRIBUTES = ['sanitize', 'whiteList', 'sanitizeFn'];    var uriAttrs = [     'background',     'cite',     'href',     'itemtype',     'longdesc',     'poster',     'src',     'xlink:href'   ];    var ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i;    var DefaultWhitelist = {     // Global attributes allowed on any supplied element below.     '*': ['class', 'dir', 'id', 'lang', 'role', 'tabindex', 'style', ARIA_ATTRIBUTE_PATTERN],     a: ['target', 'href', 'title', 'rel'],     area: [],     b: [],     br: [],     col: [],     code: [],     div: [],     em: [],     hr: [],     h1: [],     h2: [],     h3: [],     h4: [],     h5: [],     h6: [],     i: [],     img: ['src', 'alt', 'title', 'width', 'height'],     li: [],     ol: [],     p: [],     pre: [],     s: [],     small: [],     span: [],     sub: [],     sup: [],     strong: [],     u: [],     ul: []   }    /**    * A pattern that recognizes a commonly useful subset of URLs that are safe.    *    * Shoutout to Angular 7 https://github.com/angular/angular/blob/7.2.4/packages/core/src/sanitization/url_sanitizer.ts    */   var SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file):|[^&:/?#]*(?:[/?#]|$))/gi;    /**    * A pattern that matches safe data URLs. Only matches image, video and audio types.    *    * Shoutout to Angular 7 https://github.com/angular/angular/blob/7.2.4/packages/core/src/sanitization/url_sanitizer.ts    */   var DATA_URL_PATTERN = /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i;    function allowedAttribute (attr, allowedAttributeList) {     var attrName = attr.nodeName.toLowerCase()      if ($.inArray(attrName, allowedAttributeList) !== -1) {       if ($.inArray(attrName, uriAttrs) !== -1) {         return Boolean(attr.nodeValue.match(SAFE_URL_PATTERN) || attr.nodeValue.match(DATA_URL_PATTERN))       }        return true     }      var regExp = $(allowedAttributeList).filter(function (index, value) {       return value instanceof RegExp     })      // Check if a regular expression validates the attribute.     for (var i = 0, l = regExp.length; i < l; i++) {       if (attrName.match(regExp[i])) {         return true       }     }      return false   }    function sanitizeHtml (unsafeElements, whiteList, sanitizeFn) {     if (sanitizeFn && typeof sanitizeFn === 'function') {       return sanitizeFn(unsafeElements);     }      var whitelistKeys = Object.keys(whiteList);      for (var i = 0, len = unsafeElements.length; i < len; i++) {       var elements = unsafeElements[i].querySelectorAll('*');        for (var j = 0, len2 = elements.length; j < len2; j++) {         var el = elements[j];         var elName = el.nodeName.toLowerCase();          if (whitelistKeys.indexOf(elName) === -1) {           el.parentNode.removeChild(el);            continue;         }          var attributeList = [].slice.call(el.attributes);         var whitelistedAttributes = [].concat(whiteList['*'] || [], whiteList[elName] || []);          for (var k = 0, len3 = attributeList.length; k < len3; k++) {           var attr = attributeList[k];            if (!allowedAttribute(attr, whitelistedAttributes)) {             el.removeAttribute(attr.nodeName);           }         }       }     }   }    // Polyfill for browsers with no classList support   // Remove in v2   if (!('classList' in document.createElement('_'))) {     dropdownAlignRight: false,     windowPadding: 0,     virtualScroll: 600,     display: false,     sanitize: true,     sanitizeFn: null,     whiteList: DefaultWhitelist   };    if (version.major === '4') {                 emptyMenu = menuInner.firstChild.cloneNode(false),                 marginTop,                 marginBottom,                 elements = isVirtual === true ? that.selectpicker.view.visibleElements : that.selectpicker.current.elements,                 toSanitize = [];              // replace the existing UL with an empty one - this is faster than $.empty()             menuInner.replaceChild(emptyMenu, menuInner.firstChild);              for (var i = 0, visibleElementsLen = elements.length; i < visibleElementsLen; i++) {               var element = elements[i],                   elText,                   elementData;                if (that.options.sanitize) {                 elText = element.lastChild;                  if (elText) {                   elementData = that.selectpicker.current.data[i + that.selectpicker.view.position0].data;                    if (elementData && elementData.content && !elementData.sanitized) {                     toSanitize.push(elText);                     elementData.sanitized = true;                   }                 }               }                menuFragment.appendChild(element);             }              if (that.options.sanitize && toSanitize.length) {               sanitizeHtml(toSanitize, that.options.whiteList, that.options.sanitizeFn);             }              if (isVirtual === true) {           multipleSeparator = document.createTextNode(this.options.multipleSeparator),           titleFragment = elementTemplates.fragment.cloneNode(false),           showCount,           countMax,           hasContent = false;        this.togglePlaceholder();                  titleOptions.text = option.title;               } else if (thisData.content && that.options.showContent) {                 titleOptions.optionContent = thisData.content.toString();                 hasContent = true;               } else {                 if (that.options.showIcon) {                   titleOptions.optionIcon = thisData.icon;       // strip all HTML tags and trim the result, then unescape any escaped tags       this.$button[0].title = titleFragment.textContent.replace(/<[^>]*>?/g, '').trim();        if (this.options.sanitize && hasContent) {         sanitizeHtml([titleFragment], that.options.whiteList, that.options.sanitizeFn);       }        buttonInner.innerHTML = '';       buttonInner.appendChild(titleFragment);              options = typeof _option == 'object' && _option;          if (!data) {           var dataAttributes = $this.data();            for (var dataAttr in dataAttributes) {             if (dataAttributes.hasOwnProperty(dataAttr) && $.inArray(dataAttr, DISALLOWED_ATTRIBUTES) !== -1) {               delete dataAttributes[dataAttr];             }           }            var config = $.extend({}, Selectpicker.DEFAULTS, $.fn.selectpicker.defaults || {}, dataAttributes, options);           config.template = $.extend({}, Selectpicker.DEFAULTS.template, ($.fn.selectpicker.defaults ? $.fn.selectpicker.defaults.template : {}), dataAttributes.template, options.template);           $this.data('selectpicker', (data = new Selectpicker(this, config)));         } else if (options) {           for (var i in options) {

Patch:
patch: @@ -1,6 +1,128 @@
 (function ($) {
   'use strict';
 
+  var DISALLOWED_ATTRIBUTES = ['sanitize', 'whiteList', 'sanitizeFn'];
+
+  var uriAttrs = [
+    'background',
+    'cite',
+    'href',
+    'itemtype',
+    'longdesc',
+    'poster',
+    'src',
+    'xlink:href'
+  ];
+
+  var ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i;
+
+  var DefaultWhitelist = {
+    // Global attributes allowed on any supplied element below.
+    '*': ['class', 'dir', 'id', 'lang', 'role', 'tabindex', 'style', ARIA_ATTRIBUTE_PATTERN],
+    a: ['target', 'href', 'title', 'rel'],
+    area: [],
+    b: [],
+    br: [],
+    col: [],
+    code: [],
+    div: [],
+    em: [],
+    hr: [],
+    h1: [],
+    h2: [],
+    h3: [],
+    h4: [],
+    h5: [],
+    h6: [],
+    i: [],
+    img: ['src', 'alt', 'title', 'width', 'height'],
+    li: [],
+    ol: [],
+    p: [],
+    pre: [],
+    s: [],
+    small: [],
+    span: [],
+    sub: [],
+    sup: [],
+    strong: [],
+    u: [],
+    ul: []
+  }
+
+  /**
+   * A pattern that recognizes a commonly useful subset of URLs that are safe.
+   *
+   * Shoutout to Angular 7 https://github.com/angular/angular/blob/7.2.4/packages/core/src/sanitization/url_sanitizer.ts
+   */
+  var SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file):|[^&:/?#]*(?:[/?#]|$))/gi;
+
+  /**
+   * A pattern that matches safe data URLs. Only matches image, video and audio types.
+   *
+   * Shoutout to Angular 7 https://github.com/angular/angular/blob/7.2.4/packages/core/src/sanitization/url_sanitizer.ts
+   */
+  var DATA_URL_PATTERN = /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i;
+
+  function allowedAttribute (attr, allowedAttributeList) {
+    var attrName = attr.nodeName.toLowerCase()
+
+    if ($.inArray(attrName, allowedAttributeList) !== -1) {
+      if ($.inArray(attrName, uriAttrs) !== -1) {
+        return Boolean(attr.nodeValue.match(SAFE_URL_PATTERN) || attr.nodeValue.match(DATA_URL_PATTERN))
+      }
+
+      return true
+    }
+
+    var regExp = $(allowedAttributeList).filter(function (index, value) {
+      return value instanceof RegExp
+    })
+
+    // Check if a regular expression validates the attribute.
+    for (var i = 0, l = regExp.length; i < l; i++) {
+      if (attrName.match(regExp[i])) {
+        return true
+      }
+    }
+
+    return false
+  }
+
+  function sanitizeHtml (unsafeElements, whiteList, sanitizeFn) {
+    if (sanitizeFn && typeof sanitizeFn === 'function') {
+      return sanitizeFn(unsafeElements);
+    }
+
+    var whitelistKeys = Object.keys(whiteList);
+
+    for (var i = 0, len = unsafeElements.length; i < len; i++) {
+      var elements = unsafeElements[i].querySelectorAll('*');
+
+      for (var j = 0, len2 = elements.length; j < len2; j++) {
+        var el = elements[j];
+        var elName = el.nodeName.toLowerCase();
+
+        if (whitelistKeys.indexOf(elName) === -1) {
+          el.parentNode.removeChild(el);
+
+          continue;
+        }
+
+        var attributeList = [].slice.call(el.attributes);
+        var whitelistedAttributes = [].concat(whiteList['*'] || [], whiteList[elName] || []);
+
+        for (var k = 0, len3 = attributeList.length; k < len3; k++) {
+          var attr = attributeList[k];
+
+          if (!allowedAttribute(attr, whitelistedAttributes)) {
+            el.removeAttribute(attr.nodeName);
+          }
+        }
+      }
+    }
+  }
+
   // Polyfill for browsers with no classList support
   // Remove in v2
   if (!('classList' in document.createElement('_'))) {
@@ -745,7 +867,10 @@
     dropdownAlignRight: false,
     windowPadding: 0,
     virtualScroll: 600,
-    display: false
+    display: false,
+    sanitize: true,
+    sanitizeFn: null,
+    whiteList: DefaultWhitelist
   };
 
   if (version.major === '4') {
@@ -1100,13 +1225,35 @@
                 emptyMenu = menuInner.firstChild.cloneNode(false),
                 marginTop,
                 marginBottom,
-                elements = isVirtual === true ? that.selectpicker.view.visibleElements : that.selectpicker.current.elements;
+                elements = isVirtual === true ? that.selectpicker.view.visibleElements : that.selectpicker.current.elements,
+                toSanitize = [];
 
             // replace the existing UL with an empty one - this is faster than $.empty()
             menuInner.replaceChild(emptyMenu, menuInner.firstChild);
 
             for (var i = 0, visibleElementsLen = elements.length; i < visibleElementsLen; i++) {
-              menuFragment.appendChild(elements[i]);
+              var element = elements[i],
+                  elText,
+                  elementData;
+
+              if (that.options.sanitize) {
+                elText = element.lastChild;
+
+                if (elText) {
+                  elementData = that.selectpicker.current.data[i + that.selectpicker.view.position0].data;
+
+                  if (elementData && elementData.content && !elementData.sanitized) {
+                    toSanitize.push(elText);
+                    elementData.sanitized = true;
+                  }
+                }
+              }
+
+              menuFragment.appendChild(element);
+            }
+
+            if (that.options.sanitize && toSanitize.length) {
+              sanitizeHtml(toSanitize, that.options.whiteList, that.options.sanitizeFn);
             }
 
             if (isVirtual === true) {
@@ -1469,7 +1616,8 @@
           multipleSeparator = document.createTextNode(this.options.multipleSeparator),
           titleFragment = elementTemplates.fragment.cloneNode(false),
           showCount,
-          countMax;
+          countMax,
+          hasContent = false;
 
       this.togglePlaceholder();
 
@@ -1506,6 +1654,7 @@
                 titleOptions.text = option.title;
               } else if (thisData.content && that.options.showContent) {
                 titleOptions.optionContent = thisData.content.toString();
+                hasContent = true;
               } else {
                 if (that.options.showIcon) {
                   titleOptions.optionIcon = thisData.icon;
@@ -1554,6 +1703,10 @@
       // strip all HTML tags and trim the result, then unescape any escaped tags
       this.$button[0].title = titleFragment.textContent.replace(/<[^>]*>?/g, '').trim();
 
+      if (this.options.sanitize && hasContent) {
+        sanitizeHtml([titleFragment], that.options.whiteList, that.options.sanitizeFn);
+      }
+
       buttonInner.innerHTML = '';
       buttonInner.appendChild(titleFragment);
 
@@ -2833,8 +2986,16 @@
             options = typeof _option == 'object' && _option;
 
         if (!data) {
-          var config = $.extend({}, Selectpicker.DEFAULTS, $.fn.selectpicker.defaults || {}, $this.data(), options);
-          config.template = $.extend({}, Selectpicker.DEFAULTS.template, ($.fn.selectpicker.defaults ? $.fn.selectpicker.defaults.template : {}), $this.data().template, options.template);
+          var dataAttributes = $this.data();
+
+          for (var dataAttr in dataAttributes) {
+            if (dataAttributes.hasOwnProperty(dataAttr) && $.inArray(dataAttr, DISALLOWED_ATTRIBUTES) !== -1) {
+              delete dataAttributes[dataAttr];
+            }
+          }
+
+          var config = $.extend({}, Selectpicker.DEFAULTS, $.fn.selectpicker.defaults || {}, dataAttributes, options);
+          config.template = $.extend({}, Selectpicker.DEFAULTS.template, ($.fn.selectpicker.defaults ? $.fn.selectpicker.defaults.template : {}), dataAttributes.template, options.template);
           $this.data('selectpicker', (data = new Selectpicker(this, config)));
         } else if (options) {
           for (var i in options) {
path: js/bootstrap-select.js