Skip to content

Commit

Permalink
feat: sanitize torrent description in markdown
Browse files Browse the repository at this point in the history
- Purify HTML to avoid potencial XSS attacks.
- Remove external URLS to protect users' privacy.
  • Loading branch information
josecelano committed Jul 4, 2023
1 parent 34b9fb6 commit 9143736
Showing 1 changed file with 60 additions and 54 deletions.
114 changes: 60 additions & 54 deletions components/Markdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { onMounted, ref, useRestApi, watch } from "#imports";
const props = defineProps({
Expand Down Expand Up @@ -35,52 +36,79 @@ onMounted(() => {
});
function markdown (src: string) {
// Convert the markdown to HTML.
return marked(src, options);
}
async function sanitizeDescription () {
// Get the original not sanitized markdown string.
const description = markdown(props.source);
const html = markdown(props.source);
// Sanitize the description to remove any harmful HTML.
const sanitizedHtml = DOMPurify.sanitize(html);
// Parse the description as HTML to easily manipulate it.
const parser = new DOMParser();
const descriptionHtml = parser.parseFromString(sanitizedHtml, "text/html");
// Remove all external links.
const links = descriptionHtml.querySelectorAll("a");
links.forEach((link) => {
const href = link.getAttribute("href");
if (href && !href.startsWith("#")) {
link.removeAttribute("href");
}
});
// Replace the img src's with a random id and return a map
// of these ids mapped to the original url.
const [filteredDescriptionWithImageIds, imageIdUrlMap] = filterDescriptionImagesWithRandomIds(description);
// Replace images with data from the image proxy
const images = descriptionHtml.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();
}
}
}
// Get the image data using the backend's image proxy.
const imageIdDataUrlMap = await getImageDataUrlsFromUrls(imageIdUrlMap);
// Convert the description HTML back to a string.
const body = descriptionHtml.querySelector("body");
const serializer = new XMLSerializer();
let sanitizedDescriptionStr = "";
if (body) {
sanitizedDescriptionStr = serializer.serializeToString(body);
sanitizedDescriptionStr = sanitizedDescriptionStr
.replace("<body xmlns=\"http://www.w3.org/1999/xhtml\">", "")
.replace("<body>", "")
.replace("</body>", "");
}
// Replace the img id's with the proxied sources.
sanitizedDescription.value = replaceDescriptionImageIdsWithDataUrls(filteredDescriptionWithImageIds, imageIdDataUrlMap);
sanitizedDescription.value = sanitizedDescriptionStr;
}
function filterDescriptionImagesWithRandomIds (description: string): [string, Map<string, string>] {
const filteredImageMap = new Map();
// Replace all image urls with a random id.
description = description.replace(/img src="(.*?)"/gi, (match, url): string => {
const imageId = randomId(32);
filteredImageMap.set(imageId, url);
return `img src="${imageId}"`;
});
return [description, filteredImageMap];
// Returns true if the image is allowed to be displayed.
function isAllowedImage (href: string): boolean {
const allowedExtensions = ["png", "PNG", "jpg", "JPG", "jpeg", "JPEG", "gif", "GIF"];
const extension = href.split(".").pop().trim();
return allowedExtensions.includes(extension);
}
async function getImageDataUrlsFromUrls (imageMap: Map<string, string>): Promise<Map<string, string>> {
const imageDataMap: Map<string, string> = new Map();
for (const [id, url] of imageMap) {
const imageBlob = await rest.torrent.proxiedImage(url);
const imageDataUrl = await blobToDataURL(imageBlob);
imageDataMap.set(id, imageDataUrl);
}
return imageDataMap;
// Returns a base64 string ready to be use in a "src" attribute in a "img" html tag,
// like this `<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+gA…IiIiIiIiIiIiIiHyO/P85XT/jxW1glg5Erk==">`.
async function getImageDataUrl (url: string): Promise<string> {
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:
// `<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+gA…IiIiIiIiIiIiIiHyO/P85XT/jxW1glg5Erk==">`.
function blobToDataURL (blob: Blob): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
Expand All @@ -90,28 +118,6 @@ function blobToDataURL (blob: Blob): Promise<string> {
reader.readAsDataURL(blob);
});
}
function replaceDescriptionImageIdsWithDataUrls (description: string, imageIdDataUrlMap: Map<string, string>): string {
imageIdDataUrlMap.forEach((dataUrl, id) => {
description = description.replace(id, dataUrl);
});
return description;
}
function randomId (length: number) {
let result = "";
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
let counter = 0;
while (counter < length) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}
return result;
}
</script>

<style scoped>
Expand Down

0 comments on commit 9143736

Please sign in to comment.