diff --git a/webui/js/messages.js b/webui/js/messages.js index 9c5d593ed..62012ad47 100644 --- a/webui/js/messages.js +++ b/webui/js/messages.js @@ -14,6 +14,7 @@ import { formatDuration } from "./time-utils.js"; import { Scroller } from "./scroller.js"; import { callJsExtensions } from "/js/extensions.js"; import { addBlankTargetsToLinks } from "/js/html-links.js"; +import { sanitizeHtml } from "/js/safe-markdown.js"; // Delay before collapsing previous steps when a new step is added const STEP_COLLAPSE_DELAY = { @@ -708,6 +709,10 @@ export function _drawMessage({ processedContent = convertImgFilePaths(processedContent); processedContent = convertFilePaths(processedContent); processedContent = marked.parse(processedContent, { breaks: true }); + processedContent = sanitizeHtml(processedContent, { + allowDataImages: true, + allowLatex: latex, + }); processedContent = convertPathsToLinks(processedContent); processedContent = addBlankTargetsToLinks(processedContent); diff --git a/webui/js/safe-markdown.js b/webui/js/safe-markdown.js index 82f520c83..6dfb30e87 100644 --- a/webui/js/safe-markdown.js +++ b/webui/js/safe-markdown.js @@ -29,6 +29,17 @@ const DOMPURIFY_CONFIG = Object.freeze({ FORBID_TAGS: ["script", "iframe", "object", "embed", "svg", "math"], }); +const DATA_IMAGE_URL_PATTERN = + /^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,[a-z0-9+/=\s]+$/i; + +function getDompurifyConfig(options = {}) { + const config = { ...DOMPURIFY_CONFIG }; + if (options.allowLatex) { + config.ADD_TAGS = ["latex"]; + } + return config; +} + function parseGithubRepoContext(githubUrl) { if (!githubUrl || typeof githubUrl !== "string") return null; @@ -77,9 +88,16 @@ function isGithubRepoRoutePath(repoPath) { return GITHUB_REPO_ROUTE_PREFIXES.has(firstSegment); } -function isSafeUrlValue(value, attributeName) { +function isSafeUrlValue(value, attributeName, options = {}) { const normalized = String(value || "").trim(); if (!normalized) return true; + if ( + options.allowDataImages && + attributeName === "src" && + DATA_IMAGE_URL_PATTERN.test(normalized) + ) { + return true; + } if ( normalized.startsWith("#") || normalized.startsWith("/") || @@ -108,14 +126,14 @@ function isSafeUrlValue(value, attributeName) { return false; } -function stripUnsafeUrlAttributes(html) { +function stripUnsafeUrlAttributes(html, options = {}) { const doc = new DOMParser().parseFromString(html, "text/html"); doc.querySelectorAll("[href], [src]").forEach((element) => { for (const attributeName of ["href", "src"]) { if (!element.hasAttribute(attributeName)) continue; const value = element.getAttribute(attributeName) || ""; - if (!isSafeUrlValue(value, attributeName)) { + if (!isSafeUrlValue(value, attributeName, options)) { element.removeAttribute(attributeName); } } @@ -124,10 +142,10 @@ function stripUnsafeUrlAttributes(html) { return doc.body.innerHTML; } -export function sanitizeHtml(html) { +export function sanitizeHtml(html, options = {}) { if (!html || typeof html !== "string") return ""; - const sanitized = DOMPurify.sanitize(html, DOMPURIFY_CONFIG); - return stripUnsafeUrlAttributes(sanitized); + const sanitized = DOMPurify.sanitize(html, getDompurifyConfig(options)); + return stripUnsafeUrlAttributes(sanitized, options); } export function rebaseGithubReadmeHtml(html, githubUrl, branch) {