From 1246db3259fd925386f146881c0c2ee636a62e6b Mon Sep 17 00:00:00 2001 From: Richard Leek Date: Tue, 14 Apr 2026 09:05:08 -0400 Subject: [PATCH] Don't use innerHTML --- src/ui/chat/chat.ts | 93 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 70 insertions(+), 23 deletions(-) diff --git a/src/ui/chat/chat.ts b/src/ui/chat/chat.ts index ae74683c..7c408bf0 100644 --- a/src/ui/chat/chat.ts +++ b/src/ui/chat/chat.ts @@ -89,7 +89,65 @@ export class Chat extends Base { const msg = document.createElement('span'); msg.classList.add('chat-message'); - msg.innerHTML = this.replaceLinks(this.sanitize(message)); + + const urlRegex = /\b((https?:\/\/|www\.)[^\s<]+)/gi; + + let lastIndex = 0; + let match: RegExpExecArray | null = urlRegex.exec(message); + while (match !== null) { + let rawUrl = match[0]; + + // Handle trailing punctuation (common in chat) + const trailingMatch = rawUrl.match(/[.,!?)\]}]+$/); + let trailing = ''; + + if (trailingMatch) { + trailing = trailingMatch[0]; + rawUrl = rawUrl.slice(0, -trailing.length); + } + + // Append text before the URL + if (match.index > lastIndex) { + msg.appendChild( + document.createTextNode(message.slice(lastIndex, match.index)), + ); + } + + // Normalize URL (add protocol if missing) + const normalizedUrl = rawUrl.startsWith('www.') + ? `https://${rawUrl}` + : rawUrl; + + if (this.isSafeUrl(normalizedUrl)) { + const a = document.createElement('a'); + a.href = normalizedUrl; + + // Display original text (not normalized) + a.textContent = rawUrl; + + a.target = '_blank'; + a.rel = 'noopener noreferrer nofollow'; + + msg.appendChild(a); + } else { + // Fallback: treat as plain text + msg.appendChild(document.createTextNode(rawUrl)); + } + + // Append trailing punctuation back as text + if (trailing) { + msg.appendChild(document.createTextNode(trailing)); + } + + lastIndex = match.index + match[0].length; + match = urlRegex.exec(message); + } + + // Append remaining text + if (lastIndex < message.length) { + msg.appendChild(document.createTextNode(message.slice(lastIndex))); + } + msgContainer.appendChild(msg); li.appendChild(msgContainer); @@ -125,6 +183,17 @@ export class Chat extends Base { chatTab.classList.add('active'); } + private isSafeUrl(url: string): boolean { + try { + const parsed = new URL(url); + + // Only allow safe protocols + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } + } + clear() { this.localChat!.innerHTML = ''; this.globalChat!.innerHTML = ''; @@ -238,26 +307,4 @@ export class Chat extends Base { ) { this.emitter.on(event, handler); } - - private sanitize(input: string): string { - const map = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '/': '/', - }; - const sanitized = input - .replace(/[&<>"'/]/g, (char) => map[char as keyof typeof map]) - .trim(); - return sanitized; - } - - private replaceLinks(input: string): string { - const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)])/g; - return input.replace(urlRegex, (url) => { - return `${url}`; - }); - } }