From 16d9204ea823b99aa0907df305eeb2dfd0a4aafb Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 16 Aug 2024 10:30:21 +0200 Subject: [PATCH] Replace sanitize-html by dompurify in frontend It's lighter and we don't have native dependencies warnings in web browser --- client/package.json | 3 +- .../core/renderer/html-renderer.service.ts | 90 +++++++++++++++---- .../src/app/core/renderer/markdown.service.ts | 10 ++- client/src/app/modal/confirm.component.ts | 2 +- .../abuse-message-modal.component.ts | 4 +- client/yarn.lock | 41 ++++----- packages/core-utils/src/renderer/html.ts | 50 +++++------ 7 files changed, 127 insertions(+), 73 deletions(-) diff --git a/client/package.json b/client/package.json index eb1a53f7d..240b97aea 100644 --- a/client/package.json +++ b/client/package.json @@ -69,6 +69,7 @@ "@types/chart.js": "^2.9.37", "@types/core-js": "^2.5.2", "@types/debug": "^4.1.5", + "@types/dompurify": "^3.0.5", "@types/jschannel": "^1.0.0", "@types/linkifyjs": "^2.1.2", "@types/lodash-es": "^4.17.0", @@ -93,6 +94,7 @@ "chartjs-plugin-zoom": "~2.0.1", "core-js": "^3.22.8", "debug": "^4.3.1", + "dompurify": "^3.1.6", "eslint": "^8.28.0", "eslint-plugin-import": "2.29.1", "eslint-plugin-jsdoc": "^48.1.0", @@ -109,7 +111,6 @@ "ngx-uploadx": "^6.1.0", "primeng": "^17.3.1", "rxjs": "^7.3.0", - "sanitize-html": "^2.1.2", "sha.js": "^2.4.11", "socket.io-client": "^4.5.4", "stylelint": "^16.2.1", diff --git a/client/src/app/core/renderer/html-renderer.service.ts b/client/src/app/core/renderer/html-renderer.service.ts index 37741c079..c4a8c9fe7 100644 --- a/client/src/app/core/renderer/html-renderer.service.ts +++ b/client/src/app/core/renderer/html-renderer.service.ts @@ -1,41 +1,93 @@ import { Injectable } from '@angular/core' -import { getCustomMarkupSanitizeOptions, getDefaultSanitizeOptions } from '@peertube/peertube-core-utils' +import { + getDefaultSanitizedHrefAttributes, + getDefaultSanitizedSchemes, + getDefaultSanitizedTags +} from '@peertube/peertube-core-utils' +import DOMPurify, { DOMPurifyI } from 'dompurify' import { LinkifierService } from './linkifier.service' @Injectable() export class HtmlRendererService { - private sanitizeHtml: typeof import ('sanitize-html') + private simpleDomPurify: DOMPurifyI + private enhancedDomPurify: DOMPurifyI constructor (private linkifier: LinkifierService) { + this.simpleDomPurify = DOMPurify() + this.enhancedDomPurify = DOMPurify() + this.addHrefHook(this.simpleDomPurify) + this.addHrefHook(this.enhancedDomPurify) + + this.addCheckSchemesHook(this.simpleDomPurify, getDefaultSanitizedSchemes()) + this.addCheckSchemesHook(this.simpleDomPurify, [ ...getDefaultSanitizedSchemes(), 'mailto' ]) } - async convertToBr (text: string) { - await this.loadSanitizeHtml() + private addHrefHook (dompurifyInstance: DOMPurifyI) { + dompurifyInstance.addHook('afterSanitizeAttributes', node => { + if ('target' in node) { + node.setAttribute('target', '_blank') - const html = text.replace(/\r?\n/g, '
') + const rel = node.hasAttribute('rel') + ? node.getAttribute('rel') + ' ' + : '' - return this.sanitizeHtml(html, { - allowedTags: [ 'br' ] + node.setAttribute('rel', rel + 'noopener noreferrer') + } }) } - async toSafeHtml (text: string, additionalAllowedTags: string[] = []) { - const [ html ] = await Promise.all([ - // Convert possible markdown to html - this.linkifier.linkify(text), + private addCheckSchemesHook (dompurifyInstance: DOMPurifyI, schemes: string[]) { + const regex = new RegExp(`^(${schemes.join('|')}):`, 'im') - this.loadSanitizeHtml() - ]) + dompurifyInstance.addHook('afterSanitizeAttributes', node => { + const anchor = document.createElement('a') - const options = additionalAllowedTags.length !== 0 - ? getCustomMarkupSanitizeOptions(additionalAllowedTags) - : getDefaultSanitizeOptions() + if (node.hasAttribute('href')) { + anchor.href = node.getAttribute('href') - return this.sanitizeHtml(html, options) + if (anchor.protocol && !anchor.protocol.match(regex)) { + node.removeAttribute('href') + } + } + }) } - private async loadSanitizeHtml () { - this.sanitizeHtml = (await import('sanitize-html')).default + convertToBr (text: string) { + const html = text.replace(/\r?\n/g, '
') + + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: [ 'br' ] + }) + } + + async toSimpleSafeHtml (text: string) { + const html = await this.linkifier.linkify(text) + + return this.sanitize(this.simpleDomPurify, html) + } + + async toCustomPageSafeHtml (text: string, additionalAllowedTags: string[] = []) { + const html = await this.linkifier.linkify(text) + + const enhancedTags = [ 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img' ] + + return this.sanitize(this.enhancedDomPurify, html, { + additionalTags: [ ...enhancedTags, ...additionalAllowedTags ], + additionalAttributes: [ 'src', 'alt', 'style' ] + }) + } + + private sanitize (domPurify: DOMPurifyI, html: string, options: { + additionalTags?: string[] + additionalAttributes?: string[] + } = {}) { + const { additionalTags = [], additionalAttributes = [] } = options + + return domPurify.sanitize(html, { + ALLOWED_TAGS: [ ...getDefaultSanitizedTags(), ...additionalTags ], + ALLOWED_ATTR: [ ...getDefaultSanitizedHrefAttributes(), ...additionalAttributes ], + ALLOW_DATA_ATTR: true + }) } } diff --git a/client/src/app/core/renderer/markdown.service.ts b/client/src/app/core/renderer/markdown.service.ts index 015917b29..5603e5487 100644 --- a/client/src/app/core/renderer/markdown.service.ts +++ b/client/src/app/core/renderer/markdown.service.ts @@ -1,4 +1,3 @@ -import MarkdownIt from 'markdown-it' import { Injectable } from '@angular/core' import { buildVideoLink, @@ -9,6 +8,7 @@ import { TEXT_RULES, TEXT_WITH_HTML_RULES } from '@peertube/peertube-core-utils' +import MarkdownIt from 'markdown-it' import { HtmlRendererService } from './html-renderer.service' type MarkdownParsers = { @@ -140,7 +140,13 @@ export class MarkdownService { const html = this.markdownParsers[name].render(markdown) - if (config.escape) return this.htmlRenderer.toSafeHtml(html, additionalAllowedTags) + if (config.escape) { + if (name === 'customPageMarkdownIt') { + return this.htmlRenderer.toCustomPageSafeHtml(html, additionalAllowedTags) + } + + return this.htmlRenderer.toSimpleSafeHtml(html) + } return html } diff --git a/client/src/app/modal/confirm.component.ts b/client/src/app/modal/confirm.component.ts index 7e0726e96..dcb976689 100644 --- a/client/src/app/modal/confirm.component.ts +++ b/client/src/app/modal/confirm.component.ts @@ -67,7 +67,7 @@ export class ConfirmComponent implements OnInit { this.confirmButtonText = confirmButtonText || $localize`Confirm` - this.html.toSafeHtml(message) + this.html.toSimpleSafeHtml(message) .then(html => { this.message = html diff --git a/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts index a77e8e474..05a58c89f 100644 --- a/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts +++ b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts @@ -113,12 +113,12 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit { private loadMessages () { this.abuseService.listAbuseMessages(this.abuse) .subscribe({ - next: async res => { + next: res => { this.abuseMessages = [] for (const m of res.data) { this.abuseMessages.push(Object.assign(m, { - messageHtml: await this.htmlRenderer.convertToBr(m.message) + messageHtml: this.htmlRenderer.convertToBr(m.message) })) } diff --git a/client/yarn.lock b/client/yarn.lock index 107a86ed6..9701ea67d 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2806,6 +2806,13 @@ dependencies: "@types/ms" "*" +"@types/dompurify@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.5.tgz#02069a2fcb89a163bacf1a788f73cb415dd75cb7" + integrity sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg== + dependencies: + "@types/trusted-types" "*" + "@types/eslint-scope@^3.7.3": version "3.7.7" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" @@ -3071,6 +3078,11 @@ resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== +"@types/trusted-types@*": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/video.js@^7.3.40": version "7.3.58" resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.3.58.tgz#7e8cdafee25c75d6eb18f530b93ac52edff53c03" @@ -5166,11 +5178,6 @@ deepmerge-ts@^5.0.0, deepmerge-ts@^5.1.0: resolved "https://registry.yarnpkg.com/deepmerge-ts/-/deepmerge-ts-5.1.0.tgz#c55206cc4c7be2ded89b9c816cf3608884525d7a" integrity sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw== -deepmerge@^4.2.2: - version "4.3.1" - resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" - integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== - default-browser-id@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/default-browser-id/-/default-browser-id-5.0.0.tgz#a1d98bf960c15082d8a3fa69e83150ccccc3af26" @@ -5386,6 +5393,11 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" +dompurify@^3.1.6: + version "3.1.6" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.6.tgz#43c714a94c6a7b8801850f82e756685300a027e2" + integrity sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ== + domutils@^3.0.1: version "3.1.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" @@ -9248,11 +9260,6 @@ parse-node-version@^1.0.1: resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" integrity sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA== -parse-srcset@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" - integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q== - parse5-html-rewriting-stream@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz#e376d3e762d2950ccbb6bb59823fc1d7e9fdac36" @@ -9498,7 +9505,7 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.4.38, postcss@^8.2.14, postcss@^8.3.11, postcss@^8.4.23, postcss@^8.4.33, postcss@^8.4.38: +postcss@8.4.38, postcss@^8.2.14, postcss@^8.4.23, postcss@^8.4.33, postcss@^8.4.38: version "8.4.38" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== @@ -10164,18 +10171,6 @@ safe-stable-stringify@^2.3.1: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sanitize-html@^2.1.2: - version "2.13.0" - resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.13.0.tgz#71aedcdb777897985a4ea1877bf4f895a1170dae" - integrity sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA== - dependencies: - deepmerge "^4.2.2" - escape-string-regexp "^4.0.0" - htmlparser2 "^8.0.0" - is-plain-object "^5.0.0" - parse-srcset "^1.0.2" - postcss "^8.3.11" - sass-loader@14.2.1: version "14.2.1" resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-14.2.1.tgz#db9ad96b56dc1c1ea546101e76375d5b008fec70" diff --git a/packages/core-utils/src/renderer/html.ts b/packages/core-utils/src/renderer/html.ts index c12d5aa7b..a034bfcf5 100644 --- a/packages/core-utils/src/renderer/html.ts +++ b/packages/core-utils/src/renderer/html.ts @@ -1,11 +1,30 @@ +export function getDefaultSanitizedTags () { + return [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ] +} + +export function getDefaultSanitizedSchemes () { + return [ 'http', 'https' ] +} + +export function getDefaultSanitizedHrefAttributes () { + return [ 'href', 'class', 'target', 'rel' ] +} + +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// sanitize-html +// --------------------------------------------------------------------------- + export function getDefaultSanitizeOptions () { return { - allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ], - allowedSchemes: [ 'http', 'https' ], + allowedTags: getDefaultSanitizedTags(), + allowedSchemes: getDefaultSanitizedSchemes(), allowedAttributes: { - 'a': [ 'href', 'class', 'target', 'rel' ], + 'a': getDefaultSanitizedHrefAttributes(), '*': [ 'data-*' ] }, + transformTags: { a: (tagName: string, attribs: any) => { let rel = 'noopener noreferrer' @@ -29,28 +48,9 @@ export function getTextOnlySanitizeOptions () { } } -export function getCustomMarkupSanitizeOptions (additionalAllowedTags: string[] = []) { - const base = getDefaultSanitizeOptions() - - return { - allowedTags: [ - ...base.allowedTags, - ...additionalAllowedTags, - 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img' - ], - allowedSchemes: [ - ...base.allowedSchemes, - - 'mailto' - ], - allowedAttributes: { - ...base.allowedAttributes, - - 'img': [ 'src', 'alt' ], - '*': [ 'data-*', 'style' ] - } - } -} +// --------------------------------------------------------------------------- +// Manual escapes +// --------------------------------------------------------------------------- // Thanks: https://stackoverflow.com/a/12034334 export function escapeHTML (stringParam: string) {