Add sanitize template option for tooltip/popover plugins.

This commit is contained in:
Johann-S 2019-02-11 16:59:39 +02:00 committed by XhmikosR
parent bf2515ae68
commit 7bc4d2e0bc
7 changed files with 453 additions and 17 deletions

127
js/src/tools/sanitizer.js Normal file
View File

@ -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
}

View 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
}

View 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)
})
})

View File

@ -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": {

View 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>

View 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>

View 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 %}