]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Add sanitize template option for tooltip/popover plugins.
authorJohann-S <johann.servoire@gmail.com>
Mon, 11 Feb 2019 14:59:39 +0000 (16:59 +0200)
committerXhmikosR <xhmikosr@gmail.com>
Wed, 13 Feb 2019 06:32:15 +0000 (08:32 +0200)
js/src/tools/sanitizer.js [new file with mode: 0644]
js/src/tooltip.js
js/tests/unit/tooltip.js
package.json
site/docs/4.3/components/popovers.md
site/docs/4.3/components/tooltips.md
site/docs/4.3/getting-started/javascript.md

diff --git a/js/src/tools/sanitizer.js b/js/src/tools/sanitizer.js
new file mode 100644 (file)
index 0000000..00ed0d2
--- /dev/null
@@ -0,0 +1,127 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v4.3.0): tools/sanitizer.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+const uriAttrs = [
+  'background',
+  'cite',
+  'href',
+  'itemtype',
+  'longdesc',
+  'poster',
+  'src',
+  'xlink:href'
+]
+
+const ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i
+
+export const DefaultWhitelist = {
+  // Global attributes allowed on any supplied element below.
+  '*': ['class', 'dir', 'id', 'lang', 'role', 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
+ */
+const 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
+ */
+const 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) {
+  const attrName = attr.nodeName.toLowerCase()
+
+  if (allowedAttributeList.indexOf(attrName) !== -1) {
+    if (uriAttrs.indexOf(attrName) !== -1) {
+      return Boolean(attr.nodeValue.match(SAFE_URL_PATTERN) || attr.nodeValue.match(DATA_URL_PATTERN))
+    }
+
+    return true
+  }
+
+  const regExp = allowedAttributeList.filter((attrRegex) => attrRegex instanceof RegExp)
+
+  // Check if a regular expression validates the attribute.
+  for (let i = 0, l = regExp.length; i < l; i++) {
+    if (attrName.match(regExp[i])) {
+      return true
+    }
+  }
+
+  return false
+}
+
+export function sanitizeHtml(unsafeHtml, whiteList, sanitizeFn) {
+  if (unsafeHtml.length === 0) {
+    return unsafeHtml
+  }
+
+  if (sanitizeFn && typeof sanitizeFn === 'function') {
+    return sanitizeFn(unsafeHtml)
+  }
+
+  const domParser = new window.DOMParser()
+  const createdDocument = domParser.parseFromString(unsafeHtml, 'text/html')
+  const whitelistKeys = Object.keys(whiteList)
+  const elements = [].slice.call(createdDocument.body.querySelectorAll('*'))
+
+  for (let i = 0, len = elements.length; i < len; i++) {
+    const el = elements[i]
+    const elName = el.nodeName.toLowerCase()
+
+    if (whitelistKeys.indexOf(el.nodeName.toLowerCase()) === -1) {
+      el.parentNode.removeChild(el)
+
+      continue
+    }
+
+    const attributeList = [].slice.call(el.attributes)
+    const whitelistedAttributes = [].concat(whiteList['*'] || [], whiteList[elName] || [])
+
+    attributeList.forEach((attr) => {
+      if (!allowedAttribute(attr, whitelistedAttributes)) {
+        el.removeAttribute(attr.nodeName)
+      }
+    })
+  }
+
+  return createdDocument.body.innerHTML
+}
index 859ab918fff9b5711417d74248f56c3e880f3d29..e7b5b2a7f0a369c1453b42ae27269915a6e09e8d 100644 (file)
@@ -5,6 +5,10 @@
  * --------------------------------------------------------------------------
  */
 
+import {
+  DefaultWhitelist,
+  sanitizeHtml
+} from './tools/sanitizer'
 import $ from 'jquery'
 import Popper from 'popper.js'
 import Util from './util'
@@ -15,13 +19,14 @@ import Util from './util'
  * ------------------------------------------------------------------------
  */
 
-const NAME               = 'tooltip'
-const VERSION            = '4.3.0'
-const DATA_KEY           = 'bs.tooltip'
-const EVENT_KEY          = `.${DATA_KEY}`
-const JQUERY_NO_CONFLICT = $.fn[NAME]
-const CLASS_PREFIX       = 'bs-tooltip'
-const BSCLS_PREFIX_REGEX = new RegExp(`(^|\\s)${CLASS_PREFIX}\\S+`, 'g')
+const NAME                  = 'tooltip'
+const VERSION               = '4.3.0'
+const DATA_KEY              = 'bs.tooltip'
+const EVENT_KEY             = `.${DATA_KEY}`
+const JQUERY_NO_CONFLICT    = $.fn[NAME]
+const CLASS_PREFIX          = 'bs-tooltip'
+const BSCLS_PREFIX_REGEX    = new RegExp(`(^|\\s)${CLASS_PREFIX}\\S+`, 'g')
+const DISALLOWED_ATTRIBUTES = ['sanitize', 'whiteList', 'sanitizeFn']
 
 const DefaultType = {
   animation         : 'boolean',
@@ -35,7 +40,10 @@ const DefaultType = {
   offset            : '(number|string|function)',
   container         : '(string|element|boolean)',
   fallbackPlacement : '(string|array)',
-  boundary          : '(string|element)'
+  boundary          : '(string|element)',
+  sanitize          : 'boolean',
+  sanitizeFn        : '(null|function)',
+  whiteList         : 'object'
 }
 
 const AttachmentMap = {
@@ -60,7 +68,10 @@ const Default = {
   offset            : 0,
   container         : false,
   fallbackPlacement : 'flip',
-  boundary          : 'scrollParent'
+  boundary          : 'scrollParent',
+  sanitize          : true,
+  sanitizeFn        : null,
+  whiteList         : DefaultWhitelist
 }
 
 const HoverState = {
@@ -419,18 +430,27 @@ class Tooltip {
   }
 
   setElementContent($element, content) {
-    const html = this.config.html
     if (typeof content === 'object' && (content.nodeType || content.jquery)) {
       // Content is a DOM node or a jQuery
-      if (html) {
+      if (this.config.html) {
         if (!$(content).parent().is($element)) {
           $element.empty().append(content)
         }
       } else {
         $element.text($(content).text())
       }
+
+      return
+    }
+
+    if (this.config.html) {
+      if (this.config.sanitize) {
+        content = sanitizeHtml(content, this.config.whiteList, this.config.sanitizeFn)
+      }
+
+      $element.html(content)
     } else {
-      $element[html ? 'html' : 'text'](content)
+      $element.text(content)
     }
   }
 
@@ -636,9 +656,18 @@ class Tooltip {
   }
 
   _getConfig(config) {
+    const dataAttributes = $(this.element).data()
+
+    Object.keys(dataAttributes)
+      .forEach((dataAttr) => {
+        if (DISALLOWED_ATTRIBUTES.indexOf(dataAttr) !== -1) {
+          delete dataAttributes[dataAttr]
+        }
+      })
+
     config = {
       ...this.constructor.Default,
-      ...$(this.element).data(),
+      ...dataAttributes,
       ...typeof config === 'object' && config ? config : {}
     }
 
@@ -663,6 +692,10 @@ class Tooltip {
       this.constructor.DefaultType
     )
 
+    if (config.sanitize) {
+      config.template = sanitizeHtml(config.template, config.whiteList, config.sanitizeFn)
+    }
+
     return config
   }
 
index 30829d24d56d2f0e0b3bf1edfbe90a086bbdd146..e66450fb8572458e8e3552dfc9c9fc557fa3d2e4 100644 (file)
@@ -1106,4 +1106,164 @@ $(function () {
     assert.strictEqual(offset.offset, myOffset)
     assert.ok(typeof offset.fn === 'undefined')
   })
+
+  QUnit.test('should disable sanitizer', function (assert) {
+    assert.expect(1)
+
+    var $trigger = $('<a href="#" rel="tooltip" data-trigger="click" title="Another tooltip"/>')
+      .appendTo('#qunit-fixture')
+      .bootstrapTooltip({
+        sanitize: false
+      })
+
+    var tooltip = $trigger.data('bs.tooltip')
+    assert.strictEqual(tooltip.config.sanitize, false)
+  })
+
+  QUnit.test('should sanitize template by removing disallowed tags', function (assert) {
+    assert.expect(1)
+
+    var $trigger = $('<a href="#" rel="tooltip" data-trigger="click" title="Another tooltip"/>')
+      .appendTo('#qunit-fixture')
+      .bootstrapTooltip({
+        template: [
+          '<div>',
+          '  <script>console.log("oups script inserted")</script>',
+          '  <span>Some content</span>',
+          '</div>'
+        ].join('')
+      })
+
+    var tooltip = $trigger.data('bs.tooltip')
+    assert.strictEqual(tooltip.config.template.indexOf('script'), -1)
+  })
+
+  QUnit.test('should sanitize template by removing disallowed attributes', function (assert) {
+    assert.expect(1)
+
+    var $trigger = $('<a href="#" rel="tooltip" data-trigger="click" title="Another tooltip"/>')
+      .appendTo('#qunit-fixture')
+      .bootstrapTooltip({
+        template: [
+          '<div>',
+          '  <img src="x" onError="alert(\'test\')">Some content</img>',
+          '</div>'
+        ].join('')
+      })
+
+    var tooltip = $trigger.data('bs.tooltip')
+    assert.strictEqual(tooltip.config.template.indexOf('onError'), -1)
+  })
+
+  QUnit.test('should sanitize template by removing tags with XSS', function (assert) {
+    assert.expect(1)
+
+    var $trigger = $('<a href="#" rel="tooltip" data-trigger="click" title="Another tooltip"/>')
+      .appendTo('#qunit-fixture')
+      .bootstrapTooltip({
+        template: [
+          '<div>',
+          '  <a href="javascript:alert(7)">Click me</a>',
+          '  <span>Some content</span>',
+          '</div>'
+        ].join('')
+      })
+
+    var tooltip = $trigger.data('bs.tooltip')
+    assert.strictEqual(tooltip.config.template.indexOf('script'), -1)
+  })
+
+  QUnit.test('should allow custom sanitization rules', function (assert) {
+    assert.expect(2)
+
+    var $trigger = $('<a href="#" rel="tooltip" data-trigger="click" title="Another tooltip"/>')
+      .appendTo('#qunit-fixture')
+      .bootstrapTooltip({
+        template: [
+          '<a href="javascript:alert(7)">Click me</a>',
+          '<span>Some content</span>'
+        ].join(''),
+        whiteList: {
+          span: null
+        }
+      })
+
+    var tooltip = $trigger.data('bs.tooltip')
+
+    assert.strictEqual(tooltip.config.template.indexOf('<a'), -1)
+    assert.ok(tooltip.config.template.indexOf('span') !== -1)
+  })
+
+  QUnit.test('should allow passing a custom function for sanitization', function (assert) {
+    assert.expect(1)
+
+    var $trigger = $('<a href="#" rel="tooltip" data-trigger="click" title="Another tooltip"/>')
+      .appendTo('#qunit-fixture')
+      .bootstrapTooltip({
+        template: [
+          '<span>Some content</span>'
+        ].join(''),
+        sanitizeFn: function (input) {
+          return input
+        }
+      })
+
+    var tooltip = $trigger.data('bs.tooltip')
+
+    assert.ok(tooltip.config.template.indexOf('span') !== -1)
+  })
+
+  QUnit.test('should allow passing aria attributes', function (assert) {
+    assert.expect(1)
+
+    var $trigger = $('<a href="#" rel="tooltip" data-trigger="click" title="Another tooltip"/>')
+      .appendTo('#qunit-fixture')
+      .bootstrapTooltip({
+        template: [
+          '<span aria-pressed="true">Some content</span>'
+        ].join('')
+      })
+
+    var tooltip = $trigger.data('bs.tooltip')
+
+    assert.ok(tooltip.config.template.indexOf('aria-pressed') !== -1)
+  })
+
+  QUnit.test('should not sanitize element content', function (assert) {
+    assert.expect(1)
+
+    var $element = $('<div />').appendTo('#qunit-fixture')
+    var content = '<script>var test = 1;</script>'
+
+    var $trigger = $('<a href="#" rel="tooltip" data-trigger="click" title="Another tooltip"/>')
+      .appendTo('#qunit-fixture')
+      .bootstrapTooltip({
+        template: [
+          '<span aria-pressed="true">Some content</span>'
+        ].join(''),
+        html: true,
+        sanitize: false
+      })
+
+    var tooltip = $trigger.data('bs.tooltip')
+    tooltip.setElementContent($element, content)
+
+    assert.strictEqual($element[0].innerHTML, content)
+  })
+
+  QUnit.test('should not take into account sanitize in data attributes', function (assert) {
+    assert.expect(1)
+
+    var $trigger = $('<a href="#" rel="tooltip" data-sanitize="false" data-trigger="click" title="Another tooltip"/>')
+      .appendTo('#qunit-fixture')
+      .bootstrapTooltip({
+        template: [
+          '<span aria-pressed="true">Some content</span>'
+        ].join('')
+      })
+
+    var tooltip = $trigger.data('bs.tooltip')
+
+    assert.strictEqual(tooltip.config.sanitize, true)
+  })
 })
index bfbfad17bc54f4fddeef8d33e44eeed9592ec853..778b0777fe002ee65dd231f290cebf29821c7c6e 100644 (file)
     },
     {
       "path": "./dist/js/bootstrap.bundle.js",
-      "maxSize": "45 kB"
+      "maxSize": "47 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.min.js",
-      "maxSize": "21.25 kB"
+      "maxSize": "22 kB"
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "23 kB"
+      "maxSize": "25 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
-      "maxSize": "14.5 kB"
+      "maxSize": "15.5 kB"
     }
   ],
   "jspm": {
index 3e506aa296ab28046575b39a825e40e9a7f63aa3..d648c64753624617ee53c8c04e693e49e327c980 100644 (file)
@@ -140,6 +140,11 @@ Enable popovers via JavaScript:
 
 Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-`, as in `data-animation=""`.
 
+{% capture callout %}
+Note that for security reasons the `sanitize`, `sanitizeFn` and `whiteList` options cannot be supplied using data attributes.
+{% endcapture %}
+{% include callout.html content=callout type="warning" %}
+
 <table class="table table-bordered table-striped">
   <thead>
     <tr>
@@ -250,6 +255,24 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap
       <td>'scrollParent'</td>
       <td>Overflow constraint boundary of the popover. Accepts the values of <code>'viewport'</code>, <code>'window'</code>, <code>'scrollParent'</code>, or an HTMLElement reference (JavaScript only). For more information refer to Popper.js's <a href="https://popper.js.org/popper-documentation.html#modifiers..preventOverflow.boundariesElement">preventOverflow docs</a>.</td>
     </tr>
+    <tr>
+      <td>sanitize</td>
+      <td>boolean</td>
+      <td>true</td>
+      <td>Enable or disable the sanitization. If activated <code>'template'</code>, <code>'content'</code> and <code>'title'</code> options will be sanitized.</td>
+    </tr>
+    <tr>
+      <td>whiteList</td>
+      <td>object</td>
+      <td><a href="{{ site.baseurl }}/docs/{{ site.docs_version }}/getting-started/javascript/#sanitizer">Default value</a></td>
+      <td>Object which contains allowed attributes and tags</td>
+    </tr>
+    <tr>
+      <td>sanitizeFn</td>
+      <td>null | function</td>
+      <td>null</td>
+      <td>Here you can supply your own sanitize function. This can be useful if you prefer to use a dedicated library to perform sanitization.</td>
+    </tr>
   </tbody>
 </table>
 
index 41d070b1f646ea5ff251c28a4d69f586ae20b469..2fe90a671326b43ff125d4d1dd900f1826c60955 100644 (file)
@@ -143,6 +143,11 @@ Elements with the `disabled` attribute aren't interactive, meaning users cannot
 
 Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-`, as in `data-animation=""`.
 
+{% capture callout %}
+Note that for security reasons the `sanitize`, `sanitizeFn` and `whiteList` options cannot be supplied using data attributes.
+{% endcapture %}
+{% include callout.html content=callout type="warning" %}
+
 <table class="table table-bordered table-striped">
   <thead>
     <tr>
@@ -255,6 +260,24 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap
       <td>'scrollParent'</td>
       <td>Overflow constraint boundary of the tooltip. Accepts the values of <code>'viewport'</code>, <code>'window'</code>, <code>'scrollParent'</code>, or an HTMLElement reference (JavaScript only). For more information refer to Popper.js's <a href="https://popper.js.org/popper-documentation.html#modifiers..preventOverflow.boundariesElement">preventOverflow docs</a>.</td>
     </tr>
+    <tr>
+      <td>sanitize</td>
+      <td>boolean</td>
+      <td>true</td>
+      <td>Enable or disable the sanitization. If activated <code>'template'</code> and <code>'title'</code> options will be sanitized.</td>
+    </tr>
+    <tr>
+      <td>whiteList</td>
+      <td>object</td>
+      <td><a href="{{ site.baseurl }}/docs/{{ site.docs_version }}/getting-started/javascript/#sanitizer">Default value</a></td>
+      <td>Object which contains allowed attributes and tags</td>
+    </tr>
+    <tr>
+      <td>sanitizeFn</td>
+      <td>null | function</td>
+      <td>null</td>
+      <td>Here you can supply your own sanitize function. This can be useful if you prefer to use a dedicated library to perform sanitization.</td>
+    </tr>
   </tbody>
 </table>
 
index fc1f2c5a772899cbb5a7b2d206d1f7e3bb712a74..a509bd4826dfd5681e0cc8db2e9d32c37550a945 100644 (file)
@@ -139,3 +139,73 @@ Bootstrap's plugins don't fall back particularly gracefully when JavaScript is d
 All Bootstrap's JavaScript files depend on `util.js` and it has to be included alongside the other JavaScript files. If you're using the compiled (or minified) `bootstrap.js`, there is no need to include this—it's already there.
 
 `util.js` includes utility functions and a basic helper for `transitionEnd` events as well as a CSS transition emulator. It's used by the other plugins to check for CSS transition support and to catch hanging transitions.
+
+## Sanitizer
+
+Tooltips and Popovers use our built-in sanitizer to sanitize options which accept HTML.
+
+The default `whiteList` value is the following:
+
+{% highlight js %}
+var ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i
+var DefaultWhitelist = {
+  // Global attributes allowed on any supplied element below.
+  '*': ['class', 'dir', 'id', 'lang', 'role', 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: []
+}
+{% endhighlight %}
+
+If you want to add new values to this default `whiteList` you can do the following:
+
+{% highlight js %}
+var myDefaultWhiteList = $.fn.tooltip.Constructor.Default.whiteList
+
+// To allow table elements
+myDefaultWhiteList.table = []
+
+// To allow td elements and data-option attributes on td elements
+myDefaultWhiteList.td = ['data-option']
+
+// You can push your custom regex to validate your attributes.
+// Be careful about your regular expressions being too lax
+var myCustomRegex = /^data-my-app-[\w-]+/
+myDefaultWhiteList['*'].push(myCustomRegex)
+{% endhighlight %}
+
+If you want to bypass our sanitizer because you prefer to use a dedicated library, for example [DOMPurify](https://www.npmjs.com/package/dompurify), you should do the following:
+
+{% highlight js %}
+$('#yourTooltip').tooltip({
+  sanitizeFn: function (content) {
+    return DOMPurify.sanitize(content)
+  }
+})
+{% endhighlight %}