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) {