171 lines
5.0 KiB
JavaScript
171 lines
5.0 KiB
JavaScript
/**
|
||
* @file js/chatbot/utils/safe-markdown-render.js
|
||
* @description 安全的 Markdown 渲染工具 - 防止 XSS 攻击
|
||
*
|
||
* 设计理念:
|
||
* 1. 移除真正危险的东西(<script>、事件属性、javascript: URL)
|
||
* 2. 保留教学用的 HTML 代码(在代码块中自动转义)
|
||
* 3. 保留正常的格式化标签和 HTML 示例标签
|
||
* 4. 平衡安全与功能,不影响 AI 教学示例
|
||
*/
|
||
|
||
/**
|
||
* 安全地渲染 Markdown 内容
|
||
*
|
||
* @param {string} markdown - Markdown 文本
|
||
* @returns {string} 清理后的 HTML
|
||
*
|
||
* @example
|
||
* // 正常 Markdown
|
||
* safeRenderMarkdown("**粗体**")
|
||
* // => "<strong>粗体</strong>"
|
||
*
|
||
* @example
|
||
* // 代码块中的 HTML(安全显示)
|
||
* safeRenderMarkdown("```html\n<script>alert()</script>\n```")
|
||
* // => "<pre><code><script>alert()</script></code></pre>"
|
||
*
|
||
* @example
|
||
* // 恶意代码(被移除)
|
||
* safeRenderMarkdown('<img src=x onerror="alert(\'XSS\')">')
|
||
* // => "<img src='x'>" // onerror 被移除
|
||
*/
|
||
function safeRenderMarkdown(markdown) {
|
||
// 检查依赖
|
||
if (typeof marked === 'undefined') {
|
||
console.error('safeRenderMarkdown: marked is not loaded');
|
||
return escapeHtml(markdown).replace(/\n/g, '<br>');
|
||
}
|
||
|
||
if (typeof DOMPurify === 'undefined') {
|
||
console.warn('safeRenderMarkdown: DOMPurify is not loaded, falling back to unsafe rendering');
|
||
return marked.parse(markdown);
|
||
}
|
||
|
||
// 1. 使用 marked 解析 Markdown
|
||
// 代码块会被自动转义为 < >,不会执行
|
||
const rawHtml = marked.parse(markdown);
|
||
|
||
// 2. 使用 DOMPurify 清理 - 宽松配置
|
||
const cleanHtml = DOMPurify.sanitize(rawHtml, {
|
||
// 允许的标签(宽松配置,支持教学示例)
|
||
ALLOWED_TAGS: [
|
||
// === Markdown 标准标签 ===
|
||
'p', 'br', 'hr',
|
||
'strong', 'em', 'b', 'i', 'u', 's', 'del', 'ins',
|
||
'code', 'pre', 'kbd', 'samp', 'var',
|
||
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
||
'blockquote',
|
||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||
'a', 'img',
|
||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
|
||
|
||
// === 扩展标签(用于 KaTeX 和复杂布局) ===
|
||
'span', 'div', 'section', 'article',
|
||
'sup', 'sub', 'small', 'mark',
|
||
|
||
// === 教学示例可能用到的标签 ===
|
||
// AI 可能在教学中返回这些 HTML 标签作为示例
|
||
// DOMPurify 会移除事件属性,所以这些标签是安全的
|
||
'button', 'input', 'form', 'label', 'select', 'textarea', 'fieldset', 'legend',
|
||
'iframe', 'video', 'audio', 'source', 'track',
|
||
'details', 'summary',
|
||
|
||
// 注意:<script> 标签即使添加到这里也会被 DOMPurify 移除
|
||
// 这是 DOMPurify 的内置安全机制
|
||
],
|
||
|
||
// 允许的属性
|
||
ALLOWED_ATTR: [
|
||
// 链接和媒体
|
||
'href', 'src', 'alt', 'title',
|
||
|
||
// 样式和布局(KaTeX 需要 style)
|
||
'class', 'id', 'style',
|
||
'width', 'height',
|
||
|
||
// 链接属性
|
||
'target', 'rel',
|
||
|
||
// 表格属性
|
||
'colspan', 'rowspan', 'align', 'valign',
|
||
|
||
// 媒体属性
|
||
'controls', 'autoplay', 'loop', 'muted',
|
||
|
||
// 表单属性(移除了事件属性)
|
||
'type', 'name', 'value', 'placeholder', 'disabled', 'readonly',
|
||
'checked', 'selected',
|
||
|
||
// iframe 属性
|
||
'frameborder', 'allowfullscreen',
|
||
|
||
// 注意:所有 on* 事件属性会被自动移除
|
||
// 例如:onclick, onerror, onload 等
|
||
],
|
||
|
||
// 允许的 URL 协议(阻止 javascript: 等危险协议)
|
||
ALLOWED_URI_REGEXP: /^(?:(?:https?|http|ftp|mailto|tel|data):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||
|
||
// 其他安全选项
|
||
ALLOW_DATA_ATTR: false, // 禁止 data-* 属性(防止存储恶意数据)
|
||
SAFE_FOR_TEMPLATES: true, // 移除模板语法 {{}} 等
|
||
KEEP_CONTENT: true, // 移除标签但保留内容
|
||
|
||
// 返回完整的 HTML(不仅仅是 body)
|
||
WHOLE_DOCUMENT: false,
|
||
RETURN_DOM: false,
|
||
RETURN_DOM_FRAGMENT: false,
|
||
});
|
||
|
||
return cleanHtml;
|
||
}
|
||
|
||
/**
|
||
* HTML 转义(备用方案,当 DOMPurify 不可用时)
|
||
* @private
|
||
*/
|
||
function escapeHtml(unsafe) {
|
||
if (typeof unsafe !== 'string') return '';
|
||
|
||
return unsafe
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
/**
|
||
* 检查 DOMPurify 是否可用
|
||
* @returns {boolean}
|
||
*/
|
||
function isDOMPurifyAvailable() {
|
||
return typeof DOMPurify !== 'undefined';
|
||
}
|
||
|
||
/**
|
||
* 获取当前安全配置的统计信息(用于调试)
|
||
* @returns {object}
|
||
*/
|
||
function getSecurityInfo() {
|
||
return {
|
||
hasDOMPurify: isDOMPurifyAvailable(),
|
||
hasMarked: typeof marked !== 'undefined',
|
||
config: {
|
||
allowedTagsCount: 50, // 近似值
|
||
allowedAttributesCount: 25,
|
||
blocksScriptTag: true,
|
||
blocksEventAttributes: true,
|
||
blocksJavascriptUrls: true,
|
||
}
|
||
};
|
||
}
|
||
|
||
// 全局暴露(支持 file:// 协议)
|
||
if (typeof window !== 'undefined') {
|
||
window.safeRenderMarkdown = safeRenderMarkdown;
|
||
window.isDOMPurifyAvailable = isDOMPurifyAvailable;
|
||
window.getSecurityInfo = getSecurityInfo;
|
||
}
|