From 0796c4353d3d8f4bb3eb73c44bbece7b04dd8bf7 Mon Sep 17 00:00:00 2001 From: Eden Yemini <eden881@gmail.com> Date: Sat, 25 Jan 2025 23:23:25 +0200 Subject: [PATCH 1/4] Detect URLs in monitor descriptions --- src/pages/Details.vue | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/pages/Details.vue b/src/pages/Details.vue index 17d32365c..f060dc81e 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -9,7 +9,7 @@ <div>{{ monitor.id }}</div> </div> </h1> - <p v-if="monitor.description">{{ monitor.description }}</p> + <p v-if="monitor.description" v-html="processedDescription"></p> <div class="d-flex"> <div class="tags"> <Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" /> @@ -399,6 +399,19 @@ export default { screenshotURL() { return getResBaseURL() + this.monitor.screenshot + "?time=" + this.cacheTime; + }, + + processedDescription() { + if (!this.monitor.description) { + return ''; + } + + const urlPattern = /(\b(?:https?|ftp|file|smb|ssh|telnet|ldap|git):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi; + const processed = this.monitor.description.replace( + urlPattern, + url => `<a href="${this.escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${this.escapeHtml(url)}</a>` + ); + return processed; } }, @@ -656,6 +669,20 @@ export default { .replace("https://example.com/api/push/key?status=up&msg=OK&ping=", this.pushURL); this.pushMonitor.code = code; }); + }, + + /** + * Escape HTML + * @param {string} unsafe Unsafe string + * @returns {string} Safe string + */ + escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } }, }; From 15026324334f7db7f0255108789af9438b9dd1d1 Mon Sep 17 00:00:00 2001 From: Eden Yemini <eden881@gmail.com> Date: Sat, 25 Jan 2025 23:44:51 +0200 Subject: [PATCH 2/4] ESLint fixes --- src/pages/Details.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/Details.vue b/src/pages/Details.vue index f060dc81e..05eb84c17 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -9,7 +9,9 @@ <div>{{ monitor.id }}</div> </div> </h1> + <!-- eslint-disable vue/no-v-html --> <p v-if="monitor.description" v-html="processedDescription"></p> + <!--eslint-enable--> <div class="d-flex"> <div class="tags"> <Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" /> @@ -403,10 +405,10 @@ export default { processedDescription() { if (!this.monitor.description) { - return ''; + return ""; } - const urlPattern = /(\b(?:https?|ftp|file|smb|ssh|telnet|ldap|git):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi; + const urlPattern = /(\b(?:https?|ftp|file|smb|ssh|telnet|ldap|git):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi; const processed = this.monitor.description.replace( urlPattern, url => `<a href="${this.escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${this.escapeHtml(url)}</a>` From 1a3150351d6b2c5aca286c6fe820b1ae588e17f9 Mon Sep 17 00:00:00 2001 From: Eden Yemini <eden881@gmail.com> Date: Mon, 27 Jan 2025 04:18:06 +0200 Subject: [PATCH 3/4] Avoid rendering HTML, detect links explicitly --- src/components/SafeLinks.vue | 65 ++++++++++++++++++++++++++++++++++++ src/pages/Details.vue | 37 ++++---------------- 2 files changed, 71 insertions(+), 31 deletions(-) create mode 100644 src/components/SafeLinks.vue diff --git a/src/components/SafeLinks.vue b/src/components/SafeLinks.vue new file mode 100644 index 000000000..19d95cbd7 --- /dev/null +++ b/src/components/SafeLinks.vue @@ -0,0 +1,65 @@ +<template> + <span> + <template v-for="(part, index) in parts" :key="index"> + <a + v-if="part.type === 'link'" + :href="part.content" + target="_blank" + rel="noopener noreferrer" + >{{ part.content }}</a> + <span v-else>{{ part.content }}</span> + </template> + </span> +</template> + +<script> +export default { + name: "SafeLinks", + props: { + text: { + type: String, + required: true + } + }, + + computed: { + parts() { + if (!this.text) { + return []; + } + + const urlPattern = /(\b(?:https?|ftp|file|smb|ssh|telnet|ldap|git):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi; + const parts = []; + let lastIndex = 0; + + this.text.replace(urlPattern, (match, url, offset) => { + // Add text before the link + if (offset > lastIndex) { + parts.push({ + type: "text", + content: this.text.slice(lastIndex, offset) + }); + } + + // Add the link + parts.push({ + type: "link", + content: url + }); + + lastIndex = offset + match.length; + }); + + // Add remaining text after last link + if (lastIndex < this.text.length) { + parts.push({ + type: "text", + content: this.text.slice(lastIndex) + }); + } + + return parts; + } + } +}; +</script> diff --git a/src/pages/Details.vue b/src/pages/Details.vue index 05eb84c17..1a4f78b6e 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -9,9 +9,9 @@ <div>{{ monitor.id }}</div> </div> </h1> - <!-- eslint-disable vue/no-v-html --> - <p v-if="monitor.description" v-html="processedDescription"></p> - <!--eslint-enable--> + <p v-if="monitor.description"> + <SafeLinks :text="monitor.description" /> + </p> <div class="d-flex"> <div class="tags"> <Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" /> @@ -281,6 +281,7 @@ import Status from "../components/Status.vue"; import Datetime from "../components/Datetime.vue"; import CountUp from "../components/CountUp.vue"; import Uptime from "../components/Uptime.vue"; +import SafeLinks from "../components/SafeLinks.vue"; import Pagination from "v-pagination-3"; const PingChart = defineAsyncComponent(() => import("../components/PingChart.vue")); import Tag from "../components/Tag.vue"; @@ -309,7 +310,8 @@ export default { Tag, CertificateInfo, PrismEditor, - ScreenshotDialog + ScreenshotDialog, + SafeLinks, }, data() { return { @@ -401,19 +403,6 @@ export default { screenshotURL() { return getResBaseURL() + this.monitor.screenshot + "?time=" + this.cacheTime; - }, - - processedDescription() { - if (!this.monitor.description) { - return ""; - } - - const urlPattern = /(\b(?:https?|ftp|file|smb|ssh|telnet|ldap|git):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi; - const processed = this.monitor.description.replace( - urlPattern, - url => `<a href="${this.escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${this.escapeHtml(url)}</a>` - ); - return processed; } }, @@ -671,20 +660,6 @@ export default { .replace("https://example.com/api/push/key?status=up&msg=OK&ping=", this.pushURL); this.pushMonitor.code = code; }); - }, - - /** - * Escape HTML - * @param {string} unsafe Unsafe string - * @returns {string} Safe string - */ - escapeHtml(unsafe) { - return unsafe - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); } }, }; From 5df79d012919891ab0d71876ca765c682e49e792 Mon Sep 17 00:00:00 2001 From: Eden Yemini <eden881@gmail.com> Date: Mon, 27 Jan 2025 18:26:46 +0200 Subject: [PATCH 4/4] Pass description through sanitize(marked()) --- src/components/SafeLinks.vue | 65 ------------------------------------ src/pages/Details.vue | 19 +++++++---- 2 files changed, 13 insertions(+), 71 deletions(-) delete mode 100644 src/components/SafeLinks.vue diff --git a/src/components/SafeLinks.vue b/src/components/SafeLinks.vue deleted file mode 100644 index 19d95cbd7..000000000 --- a/src/components/SafeLinks.vue +++ /dev/null @@ -1,65 +0,0 @@ -<template> - <span> - <template v-for="(part, index) in parts" :key="index"> - <a - v-if="part.type === 'link'" - :href="part.content" - target="_blank" - rel="noopener noreferrer" - >{{ part.content }}</a> - <span v-else>{{ part.content }}</span> - </template> - </span> -</template> - -<script> -export default { - name: "SafeLinks", - props: { - text: { - type: String, - required: true - } - }, - - computed: { - parts() { - if (!this.text) { - return []; - } - - const urlPattern = /(\b(?:https?|ftp|file|smb|ssh|telnet|ldap|git):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi; - const parts = []; - let lastIndex = 0; - - this.text.replace(urlPattern, (match, url, offset) => { - // Add text before the link - if (offset > lastIndex) { - parts.push({ - type: "text", - content: this.text.slice(lastIndex, offset) - }); - } - - // Add the link - parts.push({ - type: "link", - content: url - }); - - lastIndex = offset + match.length; - }); - - // Add remaining text after last link - if (lastIndex < this.text.length) { - parts.push({ - type: "text", - content: this.text.slice(lastIndex) - }); - } - - return parts; - } - } -}; -</script> diff --git a/src/pages/Details.vue b/src/pages/Details.vue index 1a4f78b6e..1d068b92e 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -9,9 +9,8 @@ <div>{{ monitor.id }}</div> </div> </h1> - <p v-if="monitor.description"> - <SafeLinks :text="monitor.description" /> - </p> + <!-- eslint-disable-next-line vue/no-v-html--> + <p v-if="monitor.description" v-html="descriptionHTML"></p> <div class="d-flex"> <div class="tags"> <Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" /> @@ -281,13 +280,14 @@ import Status from "../components/Status.vue"; import Datetime from "../components/Datetime.vue"; import CountUp from "../components/CountUp.vue"; import Uptime from "../components/Uptime.vue"; -import SafeLinks from "../components/SafeLinks.vue"; import Pagination from "v-pagination-3"; const PingChart = defineAsyncComponent(() => import("../components/PingChart.vue")); import Tag from "../components/Tag.vue"; import CertificateInfo from "../components/CertificateInfo.vue"; import { getMonitorRelativeURL } from "../util.ts"; import { URL } from "whatwg-url"; +import DOMPurify from "dompurify"; +import { marked } from "marked"; import { getResBaseURL } from "../util-frontend"; import { highlight, languages } from "prismjs/components/prism-core"; import "prismjs/components/prism-clike"; @@ -310,8 +310,7 @@ export default { Tag, CertificateInfo, PrismEditor, - ScreenshotDialog, - SafeLinks, + ScreenshotDialog }, data() { return { @@ -403,6 +402,14 @@ export default { screenshotURL() { return getResBaseURL() + this.monitor.screenshot + "?time=" + this.cacheTime; + }, + + descriptionHTML() { + if (this.monitor.description != null) { + return DOMPurify.sanitize(marked(this.monitor.description)); + } else { + return ""; + } } },