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