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 = $('') + .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 = $('') + .appendTo('#qunit-fixture') + .bootstrapTooltip({ + template: [ + '
', + ' ', + ' Some content', + '
' + ].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 = $('
') + .appendTo('#qunit-fixture') + .bootstrapTooltip({ + template: [ + '
', + ' Some content', + '
' + ].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 = $('
') + .appendTo('#qunit-fixture') + .bootstrapTooltip({ + template: [ + '
', + ' Click me', + ' Some content', + '
' + ].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 = $('') + .appendTo('#qunit-fixture') + .bootstrapTooltip({ + template: [ + 'Click me', + 'Some content' + ].join(''), + whiteList: { + span: null + } + }) + + var tooltip = $trigger.data('bs.tooltip') + + assert.strictEqual(tooltip.config.template.indexOf('') + .appendTo('#qunit-fixture') + .bootstrapTooltip({ + template: [ + 'Some content' + ].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 = $('') + .appendTo('#qunit-fixture') + .bootstrapTooltip({ + template: [ + 'Some content' + ].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 = $('
').appendTo('#qunit-fixture') + var content = '' + + var $trigger = $('') + .appendTo('#qunit-fixture') + .bootstrapTooltip({ + template: [ + 'Some content' + ].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 = $('') + .appendTo('#qunit-fixture') + .bootstrapTooltip({ + template: [ + 'Some content' + ].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" %} + @@ -250,6 +255,24 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap + + + + + + + + + + + + + + + + + +
'scrollParent' Overflow constraint boundary of the popover. Accepts the values of 'viewport', 'window', 'scrollParent', or an HTMLElement reference (JavaScript only). For more information refer to Popper.js's preventOverflow docs.
sanitizebooleantrueEnable or disable the sanitization. If activated 'template', 'content' and 'title' options will be sanitized.
whiteListobjectDefault valueObject which contains allowed attributes and tags
sanitizeFnnull | functionnullHere you can supply your own sanitize function. This can be useful if you prefer to use a dedicated library to perform sanitization.
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" %} + @@ -255,6 +260,24 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap + + + + + + + + + + + + + + + + + +
'scrollParent' Overflow constraint boundary of the tooltip. Accepts the values of 'viewport', 'window', 'scrollParent', or an HTMLElement reference (JavaScript only). For more information refer to Popper.js's preventOverflow docs.
sanitizebooleantrueEnable or disable the sanitization. If activated 'template' and 'title' options will be sanitized.
whiteListobjectDefault valueObject which contains allowed attributes and tags
sanitizeFnnull | functionnullHere you can supply your own sanitize function. This can be useful if you prefer to use a dedicated library to perform sanitization.
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 %}