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) {