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, "&amp;")
+                .replace(/</g, "&lt;")
+                .replace(/>/g, "&gt;")
+                .replace(/"/g, "&quot;")
+                .replace(/'/g, "&#039;");
         }
     },
 };

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, "&amp;")
-                .replace(/</g, "&lt;")
-                .replace(/>/g, "&gt;")
-                .replace(/"/g, "&quot;")
-                .replace(/'/g, "&#039;");
         }
     },
 };

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 "";
+            }
         }
     },