diff --git a/pkg/channels/websocket/SECURITY.md b/pkg/channels/websocket/SECURITY.md
new file mode 100644
index 000000000..1f61f5709
--- /dev/null
+++ b/pkg/channels/websocket/SECURITY.md
@@ -0,0 +1,146 @@
+# WebSocket Chat Security - XSS Protection
+
+## Issue
+XSS vulnerability via markdown rendering where LLM responses containing HTML could be rendered directly without sanitization.
+
+## Solution
+Implemented comprehensive XSS protection using DOMPurify library with strict configuration.
+
+## Implementation Details
+
+### 1. DOMPurify Integration
+- **Library**: DOMPurify v3.0.8 (loaded from CDN)
+- **Location**: `/pkg/channels/websocket/chat.html`
+
+### 2. Security Configuration
+```javascript
+const DOMPURIFY_CONFIG = {
+ ALLOWED_TAGS: [
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'p', 'br', 'hr',
+ 'strong', 'em', 'b', 'i', 'u', 'del', 's', 'code', 'pre',
+ 'ul', 'ol', 'li',
+ 'blockquote',
+ 'a', 'img',
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
+ 'span', 'div'
+ ],
+ ALLOWED_ATTR: [
+ 'href', 'title', 'alt', 'src',
+ 'class', 'id'
+ ],
+ ALLOW_DATA_ATTR: false,
+ ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
+ FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
+ FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
+ KEEP_CONTENT: true
+};
+```
+
+### 3. Security Features
+
+#### Blocked Elements
+- ``
+✅ Image onerror: `
`
+✅ JavaScript URLs: `[link](javascript:alert('XSS'))`
+✅ Event handlers: `Click`
+✅ SVG attacks: ``
+✅ Iframe injection: ``
+✅ Object tags: ``
+✅ Form injection: `
`
+✅ Style injection: ``
+
+## Maintenance
+
+When updating the chat interface:
+1. Always use `sanitizeHtml()` for any HTML content from external sources
+2. Never use `innerHTML` directly with user/LLM content
+3. Keep DOMPurify library updated to latest stable version
+4. Review `DOMPURIFY_CONFIG` when adding new markdown features
+
+## References
+
+- [DOMPurify GitHub](https://github.com/cure53/DOMPurify)
+- [OWASP XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
+- [Marked.js Documentation](https://marked.js.org/)
diff --git a/pkg/channels/websocket/chat.html b/pkg/channels/websocket/chat.html
index 507d4c76b..2958767e0 100644
--- a/pkg/channels/websocket/chat.html
+++ b/pkg/channels/websocket/chat.html
@@ -889,6 +889,37 @@
headerIds: true,
mangle: false
});
+
+ // 配置 DOMPurify 以提供更强的 XSS 防护
+ const DOMPURIFY_CONFIG = {
+ ALLOWED_TAGS: [
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'p', 'br', 'hr',
+ 'strong', 'em', 'b', 'i', 'u', 'del', 's', 'code', 'pre',
+ 'ul', 'ol', 'li',
+ 'blockquote',
+ 'a', 'img',
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
+ 'span', 'div'
+ ],
+ ALLOWED_ATTR: [
+ 'href', 'title', 'alt', 'src',
+ 'class', 'id'
+ ],
+ ALLOW_DATA_ATTR: false,
+ ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
+ FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
+ FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
+ KEEP_CONTENT: true,
+ RETURN_DOM: false,
+ RETURN_DOM_FRAGMENT: false,
+ RETURN_TRUSTED_TYPE: false
+ };
+
+ // 创建安全的 sanitize 函数
+ function sanitizeHtml(html) {
+ return DOMPurify.sanitize(html, DOMPURIFY_CONFIG);
+ }
let ws;
const messagesDiv = document.getElementById('messages');
@@ -1314,7 +1345,7 @@
if (sender === 'assistant' || sender === 'system') {
// AI 回复和系统消息渲染 Markdown
const rawHtml = marked.parse(content);
- const cleanHtml = DOMPurify.sanitize(rawHtml);
+ const cleanHtml = sanitizeHtml(rawHtml);
contentDiv.innerHTML = cleanHtml;
} else {
// 用户消息保持纯文本
@@ -1337,7 +1368,7 @@
if (contentDiv) {
// Render Markdown for system messages
const rawHtml = marked.parse(newContent);
- const cleanHtml = DOMPurify.sanitize(rawHtml);
+ const cleanHtml = sanitizeHtml(rawHtml);
contentDiv.innerHTML = cleanHtml;
}
}
@@ -1362,7 +1393,7 @@
const rawHtml = marked.parse(
t.clickToRetry + '\n\n**[' + t.retryButton + ']**'
);
- const cleanHtml = DOMPurify.sanitize(rawHtml);
+ const cleanHtml = sanitizeHtml(rawHtml);
contentDiv.innerHTML = cleanHtml;
messageDiv.appendChild(contentDiv);