diff --git a/js/src/rating.js b/js/src/rating.js index 9e5cbef4b..87c8df45a 100644 --- a/js/src/rating.js +++ b/js/src/rating.js @@ -9,7 +9,7 @@ import BaseComponent from './base-component.js' import EventHandler from './dom/event-handler.js' import Manipulator from './dom/manipulator.js' import SelectorEngine from './dom/selector-engine.js' -import { DefaultAllowlist, sanitizeHtml } from './util/sanitizer.js' +import { sanitizeHtml, SVGAllowlist } from './util/sanitizer.js' import { defineJQueryPlugin, getUID } from './util/index.js' import Tooltip from './tooltip.js' @@ -47,39 +47,10 @@ const SELECTOR_DATA_TOGGLE = '[data-coreui-toggle="rating"]' const SELECTOR_RATING_ITEM_INPUT = '.rating-item-input' const SELECTOR_RATING_ITEM_LABEL = '.rating-item-label' -// js-docs-start svg-allow-list -export const svgAllowList = { - ...DefaultAllowlist, - svg: ['xmlns', 'version', 'baseprofile', 'width', 'height', 'viewbox', 'preserveaspectratio', 'aria-hidden', 'role', 'focusable'], - g: ['id', 'class', 'transform', 'style'], - path: ['id', 'class', 'd', 'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity'], - circle: ['id', 'class', 'cx', 'cy', 'r', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], - rect: ['id', 'class', 'x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], - ellipse: ['id', 'class', 'cx', 'cy', 'rx', 'ry', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], - line: ['id', 'class', 'x1', 'y1', 'x2', 'y2', 'stroke', 'stroke-width', 'stroke-opacity'], - polygon: ['id', 'class', 'points', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], - polyline: ['id', 'class', 'points', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], - text: ['id', 'class', 'x', 'y', 'dx', 'dy', 'text-anchor', 'font-family', 'font-size', 'font-weight', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], - tspan: ['id', 'class', 'x', 'y', 'dx', 'dy', 'text-anchor', 'font-family', 'font-size', 'font-weight', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], - defs: [], - symbol: ['id', 'class', 'viewbox', 'preserveaspectratio'], - use: ['id', 'class', 'x', 'y', 'width', 'height', 'href'], - image: ['id', 'class', 'x', 'y', 'width', 'height', 'href', 'preserveaspectratio', 'xlink:href'], - pattern: ['id', 'class', 'x', 'y', 'width', 'height', 'patternunits', 'patterncontentunits', 'patterntransform', 'preserveaspectratio'], - lineargradient: ['id', 'class', 'gradientunits', 'x1', 'y1', 'x2', 'y2', 'spreadmethod', 'gradienttransform'], - radialgradient: ['id', 'class', 'gradientunits', 'cx', 'cy', 'r', 'fx', 'fy', 'spreadmethod', 'gradienttransform'], - mask: ['id', 'class', 'x', 'y', 'width', 'height', 'maskunits', 'maskcontentunits', 'masktransform'], - clippath: ['id', 'class', 'clippathunits'], - marker: ['id', 'class', 'markerunits', 'markerwidth', 'markerheight', 'orient', 'preserveaspectratio', 'viewbox', 'refx', 'refy'], - title: [], - desc: [] -} -// js-docs-end svg-allow-list - const Default = { activeIcon: null, allowClear: false, - allowList: svgAllowList, + allowList: SVGAllowlist, disabled: false, highlightOnlySelected: false, icon: null, diff --git a/js/src/util/sanitizer.js b/js/src/util/sanitizer.js index a9bbdf007..b6f861ae6 100644 --- a/js/src/util/sanitizer.js +++ b/js/src/util/sanitizer.js @@ -49,6 +49,47 @@ export const DefaultAllowlist = { } // js-docs-end allow-list +// js-docs-start svg-allow-list +export const SVGAllowlist = { + ...DefaultAllowlist, + svg: ['xmlns', 'version', 'baseprofile', 'width', 'height', 'viewbox', 'preserveaspectratio', 'aria-hidden', 'role', 'focusable', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin'], + g: ['id', 'class', 'transform', 'style'], + path: ['id', 'class', 'd', 'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity'], + circle: ['id', 'class', 'cx', 'cy', 'r', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], + rect: ['id', 'class', 'x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], + ellipse: ['id', 'class', 'cx', 'cy', 'rx', 'ry', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], + line: ['id', 'class', 'x1', 'y1', 'x2', 'y2', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-opacity'], + polygon: ['id', 'class', 'points', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], + polyline: ['id', 'class', 'points', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], + text: ['id', 'class', 'x', 'y', 'dx', 'dy', 'text-anchor', 'font-family', 'font-size', 'font-weight', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], + tspan: ['id', 'class', 'x', 'y', 'dx', 'dy', 'text-anchor', 'font-family', 'font-size', 'font-weight', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity'], + defs: [], + symbol: ['id', 'class', 'viewbox', 'preserveaspectratio'], + use: ['id', 'class', 'x', 'y', 'width', 'height', 'href'], + image: ['id', 'class', 'x', 'y', 'width', 'height', 'href', 'preserveaspectratio', 'xlink:href'], + pattern: ['id', 'class', 'x', 'y', 'width', 'height', 'patternunits', 'patterncontentunits', 'patterntransform', 'preserveaspectratio'], + lineargradient: ['id', 'class', 'gradientunits', 'x1', 'y1', 'x2', 'y2', 'spreadmethod', 'gradienttransform'], + radialgradient: ['id', 'class', 'gradientunits', 'cx', 'cy', 'r', 'fx', 'fy', 'spreadmethod', 'gradienttransform'], + mask: ['id', 'class', 'x', 'y', 'width', 'height', 'maskunits', 'maskcontentunits', 'masktransform'], + clippath: ['id', 'class', 'clippathunits'], + marker: ['id', 'class', 'markerunits', 'markerwidth', 'markerheight', 'orient', 'preserveaspectratio', 'viewbox', 'refx', 'refy'], + title: [], + desc: [] +} +// js-docs-end svg-allow-list + +const ESCAPE_HTML_MAP = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' +} + +export function escapeHtml(unsafeText) { + return String(unsafeText).replace(/[&<>"']/g, character => ESCAPE_HTML_MAP[character]) +} + const uriAttributes = new Set([ 'background', 'cite', diff --git a/js/tests/unit/util/sanitizer.spec.js b/js/tests/unit/util/sanitizer.spec.js index 2b21ef2e1..daa4d465e 100644 --- a/js/tests/unit/util/sanitizer.spec.js +++ b/js/tests/unit/util/sanitizer.spec.js @@ -1,6 +1,28 @@ -import { DefaultAllowlist, sanitizeHtml } from '../../../src/util/sanitizer.js' +import { + DefaultAllowlist, escapeHtml, sanitizeHtml, SVGAllowlist +} from '../../../src/util/sanitizer.js' describe('Sanitizer', () => { + describe('escapeHtml', () => { + it('should escape HTML-significant characters', () => { + expect(escapeHtml(' & more')) + .toEqual('<img src="x" onerror='alert(1)'> & more') + }) + + it('should leave plain text untouched', () => { + expect(escapeHtml('Water and Fire')).toEqual('Water and Fire') + }) + }) + + describe('SVGAllowlist', () => { + it('should extend the default allow list with SVG elements', () => { + expect(SVGAllowlist.div).toEqual(DefaultAllowlist.div) + expect(SVGAllowlist.svg).toContain('viewbox') + expect(SVGAllowlist.path).toContain('d') + expect(SVGAllowlist.line).toContain('x1') + }) + }) + describe('sanitizeHtml', () => { it('should return the same on empty string', () => { const empty = ''