/中,DOMPurify会保留其文本内容)
- let parsedContent = parseMarkdown(role === 'assistant' ? displayContent : content);
- if (!parsedContent) {
- parsedContent = content;
- }
-
- // 使用DOMPurify清理,只添加必要的URL验证钩子(DOMPurify默认会处理事件处理器等)
- if (DOMPurify.addHook) {
- // 移除之前可能存在的钩子
- try {
- DOMPurify.removeHook('uponSanitizeAttribute');
- } catch (e) {
- // 钩子不存在,忽略
- }
-
- // 只验证URL属性,防止危险协议(DOMPurify默认会处理事件处理器、style等)
- DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
- const attrName = data.attrName.toLowerCase();
-
- // 只验证URL属性(src, href)
- if ((attrName === 'src' || attrName === 'href') && data.attrValue) {
- const value = data.attrValue.trim().toLowerCase();
- // 禁止危险协议
- if (value.startsWith('javascript:') ||
- value.startsWith('vbscript:') ||
- value.startsWith('data:text/html') ||
- value.startsWith('data:text/javascript')) {
- data.keepAttr = false;
- return;
- }
- // 对于img的src,禁止可疑的短URL(防止404和XSS)
- if (attrName === 'src' && node.tagName && node.tagName.toLowerCase() === 'img') {
- if (value.length <= 2 || /^[a-z]$/i.test(value)) {
- data.keepAttr = false;
- return;
- }
- }
- }
- });
- }
-
- formattedContent = DOMPurify.sanitize(parsedContent, defaultSanitizeConfig);
- } else if (typeof marked !== 'undefined') {
- const rawForParse = role === 'assistant' ? displayContent : content;
- const parsedContent = parseMarkdown(rawForParse);
- if (parsedContent) {
- formattedContent = parsedContent;
- } else {
- formattedContent = escapeHtml(rawForParse).replace(/\n/g, '
');
- }
+ } else if (typeof window.csMarkdownSanitize !== 'undefined') {
+ formattedContent = window.csMarkdownSanitize.formatMarkdownToHtml(
+ role === 'assistant' ? displayContent : content,
+ { profile: 'chat' }
+ );
} else {
const rawForEscape = role === 'assistant' ? displayContent : content;
formattedContent = escapeHtml(rawForEscape).replace(/\n/g, '
');
@@ -2047,21 +1953,9 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
bubble.innerHTML = formattedContent;
- // 最后的安全检查:只处理明显的可疑图片(防止404和XSS)
- // DOMPurify已经处理了大部分XSS向量,这里只做必要的补充
- const images = bubble.querySelectorAll('img');
- images.forEach(img => {
- const src = img.getAttribute('src');
- if (src) {
- const trimmedSrc = src.trim();
- // 只检查明显的可疑URL(短字符串、单个字符)
- if (trimmedSrc.length <= 2 || /^[a-z]$/i.test(trimmedSrc)) {
- img.remove();
- }
- } else {
- img.remove();
- }
- });
+ if (typeof window.csMarkdownSanitize !== 'undefined') {
+ window.csMarkdownSanitize.stripSuspiciousImages(bubble);
+ }
// 为每个表格添加独立的滚动容器
wrapTablesInBubble(bubble);
diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js
index 571261c1..a52ea6cf 100644
--- a/web/static/js/monitor.js
+++ b/web/static/js/monitor.js
@@ -320,12 +320,8 @@ function applyEinoTimelineRole(item, data) {
}
}
-// markdown 渲染(用于最终合并渲染;流式增量阶段用纯转义避免部分语法不稳定)
-const assistantMarkdownSanitizeConfig = {
- ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
- ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
- ALLOW_DATA_ATTR: false,
-};
+/** 过程详情时间线:更严消毒(无 img;整页 HTML 见 sanitize-markdown.js) */
+const timelineMarkdownOpts = { profile: 'timeline' };
function escapeHtmlLocal(text) {
if (!text) return '';
@@ -627,20 +623,10 @@ function setTimelineItemContentStreamRich(contentEl, html) {
}
function formatAssistantMarkdownContent(text) {
- const raw = text == null ? '' : String(text);
- const src = normalizeAssistantMarkdownSource(raw);
- if (typeof marked !== 'undefined') {
- try {
- marked.setOptions({ breaks: true, gfm: true });
- const parsed = marked.parse(src, { async: false });
- if (typeof DOMPurify !== 'undefined') {
- return DOMPurify.sanitize(parsed, assistantMarkdownSanitizeConfig);
- }
- return parsed;
- } catch (e) {
- return escapeHtmlLocal(raw).replace(/\n/g, '
');
- }
+ if (typeof window.csMarkdownSanitize !== 'undefined') {
+ return window.csMarkdownSanitize.formatMarkdownToHtml(text, { profile: 'chat' });
}
+ const raw = text == null ? '' : String(text);
return escapeHtmlLocal(raw).replace(/\n/g, '
');
}
@@ -668,6 +654,10 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow
wrapTablesInBubble(bubble);
}
if (copyBtn) bubble.appendChild(copyBtn);
+
+ if (typeof window.csMarkdownSanitize !== 'undefined') {
+ window.csMarkdownSanitize.stripSuspiciousImages(bubble);
+ }
}
const conversationExecutionTracker = {
@@ -1661,7 +1651,7 @@ function handleStreamEvent(event, progressElement, progressId,
const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) {
if (typeof formatMarkdown === 'function') {
- setTimelineItemContentStreamRich(contentEl, formatMarkdown(s.buffer));
+ setTimelineItemContentStreamRich(contentEl, formatMarkdown(s.buffer, timelineMarkdownOpts));
} else {
setTimelineItemContentStreamPlain(contentEl, s.buffer);
}
@@ -2004,7 +1994,7 @@ function handleStreamEvent(event, progressElement, progressId,
item.appendChild(contentEl);
}
if (typeof formatMarkdown === 'function') {
- setTimelineItemContentStreamRich(contentEl, formatMarkdown(full));
+ setTimelineItemContentStreamRich(contentEl, formatMarkdown(full, timelineMarkdownOpts));
} else {
setTimelineItemContentStreamPlain(contentEl, full);
}
@@ -3126,7 +3116,7 @@ function addTimelineItem(timeline, type, options) {
const streamBody = typeof formatTimelineStreamBody === 'function'
? formatTimelineStreamBody(options.message, options.data)
: options.message;
- content += `${formatMarkdown(streamBody)}
`;
+ content += `${formatMarkdown(streamBody, timelineMarkdownOpts)}
`;
} else if (type === 'tool_call' && options.data) {
const data = options.data;
const args = parseToolCallArgsFromData(data);
@@ -3155,7 +3145,7 @@ function addTimelineItem(timeline, type, options) {
`;
} else if (type === 'eino_agent_reply' && options.message) {
- content += `${formatMarkdown(options.message)}
`;
+ content += `${formatMarkdown(options.message, timelineMarkdownOpts)}
`;
} else if (type === 'tool_result' && options.data) {
const data = options.data;
const isError = data.isError || !data.success;
@@ -3184,14 +3174,14 @@ function addTimelineItem(timeline, type, options) {
const streamBody = typeof formatTimelineStreamBody === 'function'
? formatTimelineStreamBody(options.message, options.data)
: options.message;
- content += `${formatMarkdown(streamBody)}
`;
+ content += `${formatMarkdown(streamBody, timelineMarkdownOpts)}
`;
} else if (type === 'progress' && options.message) {
content += `${escapeHtml(options.message)} `;
} else if (type === 'user_interrupt_continue' && options.message) {
const streamBody = typeof formatTimelineStreamBody === 'function'
? formatTimelineStreamBody(options.message, options.data)
: options.message;
- content += `${formatMarkdown(streamBody)}
`;
+ content += `${formatMarkdown(streamBody, timelineMarkdownOpts)}
`;
}
item.innerHTML = content;
diff --git a/web/static/js/sanitize-markdown.js b/web/static/js/sanitize-markdown.js
new file mode 100644
index 00000000..b3f21174
--- /dev/null
+++ b/web/static/js/sanitize-markdown.js
@@ -0,0 +1,181 @@
+/**
+ * 统一的 Markdown → 安全 HTML 渲染(DOMPurify + marked)。
+ * 时间线/过程详情使用 stricter profile,整页 HTML 回退为转义 。
+ */
+(function (global) {
+ 'use strict';
+
+ const CHAT_SANITIZE_CONFIG = {
+ ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img',
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
+ ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
+ ALLOW_DATA_ATTR: false,
+ };
+
+ /** 过程详情时间线:禁止 img,减少外连与恶意资源 */
+ const TIMELINE_SANITIZE_CONFIG = {
+ ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a',
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
+ ALLOWED_ATTR: ['href', 'title', 'alt', 'class'],
+ ALLOW_DATA_ATTR: false,
+ };
+
+ const DANGEROUS_URL_PREFIXES = [
+ 'javascript:',
+ 'vbscript:',
+ 'data:text/html',
+ 'data:text/javascript',
+ 'data:application/javascript',
+ ];
+
+ let domPurifyHooksInstalled = false;
+
+ function escapeHtmlLocal(text) {
+ if (text == null || text === '') return '';
+ const div = document.createElement('div');
+ div.textContent = String(text);
+ return div.innerHTML;
+ }
+
+ function installDomPurifyHooks() {
+ if (domPurifyHooksInstalled || typeof DOMPurify === 'undefined' || !DOMPurify.addHook) {
+ return;
+ }
+ DOMPurify.addHook('uponSanitizeAttribute', function (node, data) {
+ const attrName = (data.attrName || '').toLowerCase();
+ if ((attrName !== 'src' && attrName !== 'href') || !data.attrValue) {
+ return;
+ }
+ const value = String(data.attrValue).trim().toLowerCase();
+ for (let i = 0; i < DANGEROUS_URL_PREFIXES.length; i++) {
+ if (value.indexOf(DANGEROUS_URL_PREFIXES[i]) === 0) {
+ data.keepAttr = false;
+ return;
+ }
+ }
+ if (value.indexOf('blob:') === 0) {
+ data.keepAttr = false;
+ return;
+ }
+ if (attrName === 'src' && node.tagName && node.tagName.toLowerCase() === 'img') {
+ if (value.length <= 2 || /^[a-z]$/i.test(value)) {
+ data.keepAttr = false;
+ }
+ }
+ });
+ domPurifyHooksInstalled = true;
+ }
+
+ /** 探测工具返回的整页 HTML,不宜当作富文本渲染 */
+ function isHeavyRawHtml(src) {
+ const s = String(src);
+ if (/]*>/gi);
+ return tags != null && tags.length >= 8;
+ }
+
+ function formatHtmlAsEscapedPre(text) {
+ return '' + escapeHtmlLocal(text) + '
';
+ }
+
+ function normalizeSource(text) {
+ const raw = text == null ? '' : String(text);
+ if (typeof global.normalizeAssistantMarkdownSource === 'function') {
+ return global.normalizeAssistantMarkdownSource(raw);
+ }
+ return raw;
+ }
+
+ function parseMarkdownSrc(src) {
+ if (typeof marked === 'undefined') {
+ return null;
+ }
+ try {
+ marked.setOptions({ breaks: true, gfm: true });
+ return marked.parse(src, { async: false });
+ } catch (e) {
+ console.error('Markdown 解析失败:', e);
+ return null;
+ }
+ }
+
+ function sanitizeConfigForProfile(profile) {
+ return profile === 'timeline' ? TIMELINE_SANITIZE_CONFIG : CHAT_SANITIZE_CONFIG;
+ }
+
+ /**
+ * @param {string|null|undefined} text
+ * @param {{ profile?: 'chat'|'timeline' }} [options]
+ * @returns {string} 安全 HTML
+ */
+ function formatMarkdownToHtml(text, options) {
+ const profile = (options && options.profile === 'timeline') ? 'timeline' : 'chat';
+ const src = normalizeSource(text);
+
+ if (isHeavyRawHtml(src)) {
+ return formatHtmlAsEscapedPre(src);
+ }
+
+ if (typeof DOMPurify === 'undefined') {
+ return escapeHtmlLocal(src).replace(/\n/g, '
');
+ }
+
+ installDomPurifyHooks();
+ const config = sanitizeConfigForProfile(profile);
+
+ let html;
+ const hasHtmlTags = /<[a-z][\s\S]*>/i.test(src);
+ if (typeof marked !== 'undefined' && !hasHtmlTags) {
+ const parsed = parseMarkdownSrc(src);
+ html = parsed != null ? parsed : escapeHtmlLocal(src).replace(/\n/g, '
');
+ } else if (hasHtmlTags) {
+ html = src;
+ } else {
+ html = escapeHtmlLocal(src).replace(/\n/g, '
');
+ }
+
+ return DOMPurify.sanitize(html, config);
+ }
+
+ function sanitizeRichHtml(html, profile) {
+ if (typeof DOMPurify === 'undefined') {
+ return null;
+ }
+ installDomPurifyHooks();
+ return DOMPurify.sanitize(html, sanitizeConfigForProfile(profile || 'chat'));
+ }
+
+ function stripSuspiciousImages(root) {
+ if (!root || !root.querySelectorAll) {
+ return;
+ }
+ root.querySelectorAll('img').forEach(function (img) {
+ const src = (img.getAttribute('src') || '').trim();
+ if (!src || src.length <= 2 || /^[a-z]$/i.test(src)) {
+ img.remove();
+ }
+ });
+ }
+
+ global.csMarkdownSanitize = {
+ CHAT_SANITIZE_CONFIG: CHAT_SANITIZE_CONFIG,
+ TIMELINE_SANITIZE_CONFIG: TIMELINE_SANITIZE_CONFIG,
+ installDomPurifyHooks: installDomPurifyHooks,
+ formatMarkdownToHtml: formatMarkdownToHtml,
+ sanitizeRichHtml: sanitizeRichHtml,
+ isHeavyRawHtml: isHeavyRawHtml,
+ escapeHtmlLocal: escapeHtmlLocal,
+ stripSuspiciousImages: stripSuspiciousImages,
+ };
+
+ global.formatMarkdown = function formatMarkdown(text, options) {
+ return formatMarkdownToHtml(text, options);
+ };
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/web/static/js/settings.js b/web/static/js/settings.js
index a973b5e8..69d341ae 100644
--- a/web/static/js/settings.js
+++ b/web/static/js/settings.js
@@ -1375,11 +1375,6 @@ function fillVisionConfigFromCurrent(v) {
const d = (v.detail || 'low').toString().toLowerCase();
det.value = ['low', 'auto', 'high'].includes(d) ? d : 'low';
}
- const rootsEl = document.getElementById('vision-allowed-roots');
- if (rootsEl) {
- const roots = Array.isArray(v.allowed_roots) ? v.allowed_roots : [];
- rootsEl.value = roots.join('\n');
- }
syncVisionFormEnabled();
}
@@ -1388,8 +1383,6 @@ function collectVisionConfigFromForm() {
const n = parseInt(document.getElementById(id)?.value, 10);
return Number.isNaN(n) ? fallback : n;
};
- const rootsRaw = document.getElementById('vision-allowed-roots')?.value || '';
- const allowed_roots = rootsRaw.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
const provider = document.getElementById('vision-provider')?.value.trim() || '';
return {
enabled: document.getElementById('vision-enabled')?.checked === true,
@@ -1403,8 +1396,7 @@ function collectVisionConfigFromForm() {
jpeg_quality: parseIntOr('vision-jpeg-quality', 82),
max_payload_bytes: parseIntOr('vision-max-payload-bytes', 524288),
skip_preprocess_below_bytes: parseIntOr('vision-skip-preprocess-bytes', 2097152),
- detail: document.getElementById('vision-detail')?.value || 'low',
- allowed_roots: allowed_roots
+ detail: document.getElementById('vision-detail')?.value || 'low'
};
}
diff --git a/web/templates/index.html b/web/templates/index.html
index d038a876..4e5337b4 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -2544,10 +2544,6 @@
-
-
-
-
@@ -3512,6 +3508,7 @@
+