diff --git a/components/Markdown.vue b/components/Markdown.vue index 7ceeb81d..745904dc 100644 --- a/components/Markdown.vue +++ b/components/Markdown.vue @@ -6,6 +6,7 @@ diff --git a/package-lock.json b/package-lock.json index c8128eb8..02dc0887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@heroicons/vue": "^2.0.18", "daisyui": "^3.1.7", + "dompurify": "^3.0.4", "marked": "^5.1.0", "notiwind-ts": "^2.0.2", "torrust-index-api-lib": "^0.2.0", @@ -18,6 +19,7 @@ "@nuxtjs/eslint-config-typescript": "^12.0.0", "@nuxtjs/tailwindcss": "^6.8.0", "@tailwindcss/typography": "^0.5.9", + "@types/dompurify": "^3.0.2", "@types/marked": "^5.0.0", "@types/node": "^20.3.2", "@typescript-eslint/eslint-plugin": "^5.60.0", @@ -2826,6 +2828,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@types/dompurify": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.2.tgz", + "integrity": "sha512-YBL4ziFebbbfQfH5mlC+QTJsvh0oJUrWbmxKMyEdL7emlHJqGR2Qb34TEFKj+VCayBvjKy3xczMFNhugThUsfQ==", + "dev": true, + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/eslint": { "version": "8.40.2", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.40.2.tgz", @@ -2922,6 +2933,12 @@ "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", "dev": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", + "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==", + "dev": true + }, "node_modules/@types/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz", @@ -6512,6 +6529,11 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.4.tgz", + "integrity": "sha512-ae0mA+Qiqp6C29pqZX3fQgK+F91+F7wobM/v8DRzDqJdZJELXiFUx4PP4pK/mzUS0xkiSEx3Ncd9gr69jg3YsQ==" + }, "node_modules/domutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", diff --git a/package.json b/package.json index a88fb3f5..b9636041 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@nuxtjs/eslint-config-typescript": "^12.0.0", "@nuxtjs/tailwindcss": "^6.8.0", "@tailwindcss/typography": "^0.5.9", + "@types/dompurify": "^3.0.2", "@types/marked": "^5.0.0", "@types/node": "^20.3.2", "@typescript-eslint/eslint-plugin": "^5.60.0", @@ -32,6 +33,7 @@ "dependencies": { "@heroicons/vue": "^2.0.18", "daisyui": "^3.1.7", + "dompurify": "^3.0.4", "marked": "^5.1.0", "notiwind-ts": "^2.0.2", "torrust-index-api-lib": "^0.2.0", diff --git a/project-words.txt b/project-words.txt index 1a1ceb01..7bfebf84 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1,4 +1,5 @@ composables +dompurify heroicons notiwind Nuxt diff --git a/src/domain/services/sanitizer.ts b/src/domain/services/sanitizer.ts new file mode 100644 index 00000000..e829b44a --- /dev/null +++ b/src/domain/services/sanitizer.ts @@ -0,0 +1,97 @@ +import DOMPurify from "dompurify"; +import { useRestApi } from "#imports"; + +const rest = useRestApi().value; + +const allowedTags = ["h1", "h2", "h3", "h4", "h5", "h6", "em", "strong", "del", "a", "img", "ul", "ol", "li", "hr"]; +const allowedImageExtensions = ["png", "PNG", "jpg", "JPG", "jpeg", "JPEG", "gif", "GIF"]; + +export async function sanitize (html: string) { + const safeHtml = remove_harmful_code(html); + const htmlWithNoUserTracking = await remove_user_tracking(safeHtml); + return htmlWithNoUserTracking; +} + +function remove_harmful_code (html: string) { + return DOMPurify.sanitize(html, { ALLOWED_TAGS: allowedTags }); +} + +async function remove_user_tracking (html: string) { + // Parse the description as HTML to easily manipulate it. + const parser = new DOMParser(); + + const htmlDoc = parser.parseFromString(html, "text/html"); + + remove_all_external_links(htmlDoc); + await replace_images_with_proxied_images(htmlDoc); + + return document_to_html(htmlDoc); +} + +function remove_all_external_links (htmlDoc: Document) { + const links = htmlDoc.querySelectorAll("a"); + links.forEach((link) => { + const href = link.getAttribute("href"); + if (href && !href.startsWith("#")) { + link.removeAttribute("href"); + } + }); +} + +async function replace_images_with_proxied_images (htmlDoc: Document) { + const images = htmlDoc.querySelectorAll("img"); + for (let i = 0; i < images.length; i++) { + const img = images[i]; + const src = img.getAttribute("src"); + + if (src) { + if (isAllowedImage(src)) { + const imageDataSrc = await getImageDataUrl(src); + img.setAttribute("src", imageDataSrc); + } else { + img.remove(); + } + } + } +} + +function document_to_html (descriptionHtml: Document) { + const body = descriptionHtml.querySelector("body"); + const serializer = new XMLSerializer(); + let html = ""; + if (body) { + html = serializer.serializeToString(body); + html = html + .replace("", "") + .replace("", "") + .replace("", ""); + } + return html; +} + +// Returns true if the image is allowed to be displayed. +function isAllowedImage (href: string): boolean { + const extension = href.split(".").pop().trim(); + return allowedImageExtensions.includes(extension); +} + +// Returns a base64 string ready to be use in a "src" attribute in a "img" html tag, +// like this ``. +async function getImageDataUrl (url: string): Promise { + const imageBlob = await rest.torrent.proxiedImage(url); + const data = await blobToDataURL(imageBlob); + return data; +} + +// Convert binary data into a base64 encoded string ready to be use in a "src" +// attribute in a "img" html tag, like the following: +// ``. +function blobToDataURL (blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = _e => resolve(reader.result as string); + reader.onerror = _e => reject(reader.error); + reader.onabort = _e => reject(new Error("Read aborted")); + reader.readAsDataURL(blob); + }); +}