diff options
-rw-r--r-- | js/src/tools/sanitizer.js | 127 | ||||
-rw-r--r-- | js/src/tooltip.js | 59 | ||||
-rw-r--r-- | js/tests/unit/tooltip.js | 160 | ||||
-rw-r--r-- | package.json | 8 | ||||
-rw-r--r-- | site/docs/4.3/components/popovers.md | 23 | ||||
-rw-r--r-- | site/docs/4.3/components/tooltips.md | 23 | ||||
-rw-r--r-- | site/docs/4.3/getting-started/javascript.md | 70 |
7 files changed, 453 insertions, 17 deletions
diff --git a/js/src/tools/sanitizer.js b/js/src/tools/sanitizer.js new file mode 100644 index 0000000000..00ed0d29ee --- /dev/null +++ b/js/src/tools/sanitizer.js @@ -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 +} diff --git a/js/src/tooltip.js b/js/src/tooltip.js index 859ab918ff..e7b5b2a7f0 100644 --- a/js/src/tooltip.js +++ b/js/src/tooltip.js @@ -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 } diff --git a/js/tests/unit/tooltip.js b/js/tests/unit/tooltip.js index 30829d24d5..e66450fb85 100644 --- a/js/tests/unit/tooltip.js +++ b/js/tests/unit/tooltip.js @@ -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) + }) }) diff --git a/package.json b/package.json index bfbfad17bc..778b0777fe 100644 --- a/package.json +++ b/package.json @@ -182,19 +182,19 @@ }, { "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": { diff --git a/site/docs/4.3/components/popovers.md b/site/docs/4.3/components/popovers.md index 3e506aa296..d648c64753 100644 --- a/site/docs/4.3/components/popovers.md +++ b/site/docs/4.3/components/popovers.md @@ -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> diff --git a/site/docs/4.3/components/tooltips.md b/site/docs/4.3/components/tooltips.md index 41d070b1f6..2fe90a6713 100644 --- a/site/docs/4.3/components/tooltips.md +++ b/site/docs/4.3/components/tooltips.md @@ -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> diff --git a/site/docs/4.3/getting-started/javascript.md b/site/docs/4.3/getting-started/javascript.md index fc1f2c5a77..a509bd4826 100644 --- a/site/docs/4.3/getting-started/javascript.md +++ b/site/docs/4.3/getting-started/javascript.md @@ -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 %} |