fix(webui): sanitize chat markdown rendering

Muhammad Ali committed May 12, 2026 at 16:40 UTC 35cfcb3be3f021882e4019da263bd1241e730a6e
2 files changed +29 -6
webui/js/messages.js
+5
@@ -14,6 +14,7 @@ import { formatDuration } from "./time-utils.js";
14 import { Scroller } from "./scroller.js";
15 import { callJsExtensions } from "/js/extensions.js";
16 import { addBlankTargetsToLinks } from "/js/html-links.js";
17 +import { sanitizeHtml } from "/js/safe-markdown.js";
18
19 // Delay before collapsing previous steps when a new step is added
20 const STEP_COLLAPSE_DELAY = {
@@ -708,6 +709,10 @@ export function _drawMessage({
709 processedContent = convertImgFilePaths(processedContent);
710 processedContent = convertFilePaths(processedContent);
711 processedContent = marked.parse(processedContent, { breaks: true });
712 + processedContent = sanitizeHtml(processedContent, {
713 + allowDataImages: true,
714 + allowLatex: latex,
715 + });
716 processedContent = convertPathsToLinks(processedContent);
717 processedContent = addBlankTargetsToLinks(processedContent);
718
webui/js/safe-markdown.js
+24 -6
@@ -29,6 +29,17 @@ const DOMPURIFY_CONFIG = Object.freeze({
29 FORBID_TAGS: ["script", "iframe", "object", "embed", "svg", "math"],
30 });
31
32 +const DATA_IMAGE_URL_PATTERN =
33 + /^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,[a-z0-9+/=\s]+$/i;
34 +
35 +function getDompurifyConfig(options = {}) {
36 + const config = { ...DOMPURIFY_CONFIG };
37 + if (options.allowLatex) {
38 + config.ADD_TAGS = ["latex"];
39 + }
40 + return config;
41 +}
42 +
43 function parseGithubRepoContext(githubUrl) {
44 if (!githubUrl || typeof githubUrl !== "string") return null;
45
@@ -77,9 +88,16 @@ function isGithubRepoRoutePath(repoPath) {
88 return GITHUB_REPO_ROUTE_PREFIXES.has(firstSegment);
89 }
90
80 -function isSafeUrlValue(value, attributeName) {
91 +function isSafeUrlValue(value, attributeName, options = {}) {
92 const normalized = String(value || "").trim();
93 if (!normalized) return true;
94 + if (
95 + options.allowDataImages &&
96 + attributeName === "src" &&
97 + DATA_IMAGE_URL_PATTERN.test(normalized)
98 + ) {
99 + return true;
100 + }
101 if (
102 normalized.startsWith("#") ||
103 normalized.startsWith("/") ||
@@ -108,14 +126,14 @@ function isSafeUrlValue(value, attributeName) {
126 return false;
127 }
128
111 -function stripUnsafeUrlAttributes(html) {
129 +function stripUnsafeUrlAttributes(html, options = {}) {
130 const doc = new DOMParser().parseFromString(html, "text/html");
131
132 doc.querySelectorAll("[href], [src]").forEach((element) => {
133 for (const attributeName of ["href", "src"]) {
134 if (!element.hasAttribute(attributeName)) continue;
135 const value = element.getAttribute(attributeName) || "";
118 - if (!isSafeUrlValue(value, attributeName)) {
136 + if (!isSafeUrlValue(value, attributeName, options)) {
137 element.removeAttribute(attributeName);
138 }
139 }
@@ -124,10 +142,10 @@ function stripUnsafeUrlAttributes(html) {
142 return doc.body.innerHTML;
143 }
144
127 -export function sanitizeHtml(html) {
145 +export function sanitizeHtml(html, options = {}) {
146 if (!html || typeof html !== "string") return "";
129 - const sanitized = DOMPurify.sanitize(html, DOMPURIFY_CONFIG);
130 - return stripUnsafeUrlAttributes(sanitized);
147 + const sanitized = DOMPurify.sanitize(html, getDompurifyConfig(options));
148 + return stripUnsafeUrlAttributes(sanitized, options);
149 }
150
151 export function rebaseGithubReadmeHtml(html, githubUrl, branch) {