diff --git a/CHANGES.rst b/CHANGES.rst index f103db0c937..f77fe68ce48 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,6 +14,8 @@ Improvements - Warn when a user cannot create an event in the current category (:pr:`5572`) - Display all contributions in 'My contributions' and not just those with submitter privileges (:pr:`5575`) +- Apply stronger sanitization on rich-text content pasted into CKEditor + (:issue:`5560`, :pr:`5571`) Bugfixes ^^^^^^^^ diff --git a/indico/web/client/js/ckeditor.js b/indico/web/client/js/ckeditor.js index 2ec0a489ddf..26559ceba4f 100644 --- a/indico/web/client/js/ckeditor.js +++ b/indico/web/client/js/ckeditor.js @@ -5,6 +5,8 @@ // modify it under the terms of the MIT License; see the // LICENSE file for more details. +import {sanitizeHtml} from './utils/sanitize'; + export const getConfig = ({ images = true, imageUploadURL = null, @@ -184,3 +186,22 @@ export const getConfig = ({ } : undefined, }); + +// Sanitize HTML pasted into ckeditor. +// Use it with the clipboardInput event and pass the editor instance: +// +// editor.editing.view.document.on('clipboardInput', sanitizeHtmlOnPaste(editor)) +// +// More info: https://ckeditor.com/docs/ckeditor5/latest/framework/guides/deep-dive/clipboard.html +export function sanitizeHtmlOnPaste(editor) { + return (ev, data) => { + if (data.method !== 'paste' || data.content) { + return; + } + const dataTransfer = data.dataTransfer; + const contentData = dataTransfer.getData('text/html'); + if (contentData) { + data.content = editor.data.htmlProcessor.toView(sanitizeHtml(contentData)); + } + }; +} diff --git a/indico/web/client/js/jquery/widgets/jinja/ckeditor_widget.js b/indico/web/client/js/jquery/widgets/jinja/ckeditor_widget.js index 76e23d03210..b5340c1236a 100644 --- a/indico/web/client/js/jquery/widgets/jinja/ckeditor_widget.js +++ b/indico/web/client/js/jquery/widgets/jinja/ckeditor_widget.js @@ -8,7 +8,7 @@ import ClassicEditor from 'ckeditor'; import _ from 'lodash'; -import {getConfig} from 'indico/ckeditor'; +import {getConfig, sanitizeHtmlOnPaste} from 'indico/ckeditor'; (function(global) { global.setupCKEditorWidget = async function setupCKEditorWidget(options) { @@ -26,6 +26,10 @@ import {getConfig} from 'indico/ckeditor'; field.dispatchEvent(new Event('change', {bubbles: true})); }, 250) ); + // Sanitize pasted HTML + editor.editing.view.document.on('clipboardInput', sanitizeHtmlOnPaste(editor), { + priority: 'normal', + }); editor.editing.view.change(writer => { writer.setStyle( 'width', diff --git a/indico/web/client/js/react/components/TextEditor.jsx b/indico/web/client/js/react/components/TextEditor.jsx index 2d250dff4b8..8401d4ce656 100644 --- a/indico/web/client/js/react/components/TextEditor.jsx +++ b/indico/web/client/js/react/components/TextEditor.jsx @@ -12,7 +12,7 @@ import React, {useMemo, useState} from 'react'; import {Field} from 'react-final-form'; import {Dimmer, Loader} from 'semantic-ui-react'; -import {getConfig} from 'indico/ckeditor'; +import {getConfig, sanitizeHtmlOnPaste} from 'indico/ckeditor'; import {Translate} from 'indico/react/i18n'; import {FinalField} from '../forms'; @@ -41,6 +41,10 @@ export default function TextEditor({ writer.setStyle('width', width, editor.editing.view.document.getRoot()); writer.setStyle('height', height, editor.editing.view.document.getRoot()); }); + // Sanitize pasted HTML + editor.editing.view.document.on('clipboardInput', sanitizeHtmlOnPaste(editor), { + priority: 'normal', + }); if (setValidationError) { editor.plugins._plugins.get('SourceEditing').on('change:isSourceEditingMode', evt => { if (evt.source.isSourceEditingMode) { diff --git a/indico/web/client/js/utils/sanitize.js b/indico/web/client/js/utils/sanitize.js new file mode 100644 index 00000000000..d0f4906aa15 --- /dev/null +++ b/indico/web/client/js/utils/sanitize.js @@ -0,0 +1,70 @@ +// This file is part of Indico. +// Copyright (C) 2002 - 2022 CERN +// +// Indico is free software; you can redistribute it and/or +// modify it under the terms of the MIT License; see the +// LICENSE file for more details. + +import _sanitizeHtml from 'sanitize-html'; + +/* eslint array-element-newline: off */ + +// The following are whitelisted tags, attributes & CSS styles for +// sanitizing HTML provided by the user, such as when pasting into ckeditor. +// It is the same configuration that is used by bleach on the server-side (except for the legacy attributes). +// See 'indico/util/string.py' + +// prettier-ignore +const ALLOWED_TAGS = [ + // bleach.ALLOWED_TAGS + 'a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'strong', 'ul', + // BLEACH_ALLOWED_TAGS + 'sup', 'sub', 'small', 'br', 'p', 'table', 'thead', 'tbody', 'th', 'tr', 'td', 'img', 'hr', 'h1', 'h2', 'h3', 'h4', + 'h5', 'h6', 'pre', 'dl', 'dd', 'dt', 'figure', 'blockquote', + // BLEACH_ALLOWED_TAGS_HTML + 'address', 'area', 'bdo', 'big', 'caption', 'center', 'cite', 'col', 'colgroup', 'del', 'dfn', 'dir', 'div', + 'fieldset', 'font', 'ins', 'kbd', 'legend', 'map', 'menu', 'q', 's', 'samp', 'span', 'strike', 'tfoot', 'tt', 'u', + 'var' +]; + +const ALLOWED_ATTRIBUTES = { + '*': ['style'], + // bleach.ALLOWED_ATTRIBUTES + 'a': ['href', 'title'], + 'abbr': ['title'], + 'acronym': ['title'], + // BLEACH_ALLOWED_ATTRIBUTES + 'img': ['src', 'alt', 'style'], +}; + +// prettier-ignore +const ALLOWED_STYLES = [ + 'background-color', 'border-top-color', 'border-top-style', 'border-top-width', 'border-top', 'border-right-color', + 'border-right-style', 'border-right-width', 'border-right', 'border-bottom-color', 'border-bottom-style', + 'border-bottom-width', 'border-bottom', 'border-left-color', 'border-left-style', 'border-left-width', + 'border-left', 'border-color', 'border-style', 'border-width', 'border', 'bottom', 'border-collapse', + 'border-spacing', 'color', 'clear', 'clip', 'caption-side', 'display', 'direction', 'empty-cells', 'float', + 'font-size', 'font-family', 'font-style', 'font', 'font-variant', 'font-weight', 'font-size-adjust', 'font-stretch', + 'height', 'left', 'list-style-type', 'list-style-position', 'line-height', 'letter-spacing', 'marker-offset', + 'margin', 'margin-left', 'margin-right', 'margin-top', 'margin-bottom', 'max-height', 'min-height', 'max-width', + 'min-width', 'marks', 'overflow', 'outline-color', 'outline-style', 'outline-width', 'outline', 'orphans', + 'position', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'padding', 'page', 'page-break-after', + 'page-break-before', 'page-break-inside', 'quotes', 'right', 'size', 'text-align', 'top', 'table-layout', + 'text-decoration', 'text-indent', 'text-shadow', 'text-transform', 'unicode-bidi', 'visibility', 'vertical-align', + 'width', 'widows', 'white-space', 'word-spacing', 'word-wrap', 'z-index' +] + +// Sanitize user-provided HTML, such as when pasting into ckeditor. +export function sanitizeHtml(dirty) { + const matchAny = /^.*$/; + const allowedStyles = ALLOWED_STYLES.reduce((styles, name) => { + styles[name] = [matchAny]; + return styles; + }, {}); + + return _sanitizeHtml(dirty, { + allowedTags: ALLOWED_TAGS, + allowedAttributes: ALLOWED_ATTRIBUTES, + allowedStyles, + }); +} diff --git a/package-lock.json b/package-lock.json index 2069995dbd9..f1b2e332faf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,7 @@ "redux-router-querystring": "^0.0.11", "redux-thunk": "^2.4.1", "reselect": "^4.1.6", + "sanitize-html": "^2.7.3", "semantic-ui-react": "^2.1.3", "slugify": "^1.6.5", "tablesorter": "^2.31.3", @@ -5546,7 +5547,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -5729,7 +5729,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, "funding": [ { "type": "github", @@ -6031,7 +6030,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -21524,6 +21522,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, "node_modules/parse5": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.0.0.tgz", @@ -21721,7 +21724,6 @@ "version": "8.4.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", "integrity": "sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==", - "dev": true, "funding": [ { "type": "opencollective", @@ -23799,6 +23801,93 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sanitize-html": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.7.3.tgz", + "integrity": "sha512-jMaHG29ak4miiJ8wgqA1849iInqORgNv7SLfSw9LtfOhEUQ1C0YHKH73R+hgyufBW9ZFeJrb057k9hjlfBCVlw==", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^6.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, + "node_modules/sanitize-html/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/sanitize-html/node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sass-graph": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-4.0.0.tgz", @@ -24192,7 +24281,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -30145,8 +30233,7 @@ "deepmerge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" }, "define-properties": { "version": "1.1.4", @@ -30282,8 +30369,7 @@ "domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" }, "domexception": { "version": "2.0.1", @@ -30517,8 +30603,7 @@ "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, "escodegen": { "version": "2.0.0", @@ -44963,6 +45048,11 @@ "lines-and-columns": "^1.1.6" } }, + "parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, "parse5": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.0.0.tgz", @@ -45111,7 +45201,6 @@ "version": "8.4.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", "integrity": "sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==", - "dev": true, "requires": { "nanoid": "^3.3.4", "picocolors": "^1.0.0", @@ -46646,6 +46735,70 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "sanitize-html": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.7.3.tgz", + "integrity": "sha512-jMaHG29ak4miiJ8wgqA1849iInqORgNv7SLfSw9LtfOhEUQ1C0YHKH73R+hgyufBW9ZFeJrb057k9hjlfBCVlw==", + "requires": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^6.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + }, + "dependencies": { + "dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + } + }, + "domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "requires": { + "domelementtype": "^2.2.0" + } + }, + "domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + } + }, + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==" + }, + "htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" + } + } + }, "sass-graph": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-4.0.0.tgz", @@ -46934,8 +47087,7 @@ "source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" }, "source-map-support": { "version": "0.5.21", diff --git a/package.json b/package.json index 1230fa72a71..c5b7fe40032 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "redux-router-querystring": "^0.0.11", "redux-thunk": "^2.4.1", "reselect": "^4.1.6", + "sanitize-html": "^2.7.3", "semantic-ui-react": "^2.1.3", "slugify": "^1.6.5", "tablesorter": "^2.31.3",