diff --git a/docs/images/pipeline.png b/docs/images/pipeline.png new file mode 100644 index 000000000..ae9c31692 Binary files /dev/null and b/docs/images/pipeline.png differ diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 9593dbc5e..ad9a46953 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -3,6 +3,13 @@ server { server_name localhost; client_max_body_size 50M; + # 安全头配置 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: http:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; frame-ancestors 'self';" always; + # 错误日志配置 error_log /var/log/nginx/error.log warn; access_log /var/log/nginx/access.log; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bad250f96..d16df4bee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,9 @@ "version": "0.1.0", "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", + "@types/dompurify": "^3.0.5", "axios": "^1.8.4", + "dompurify": "^3.2.6", "marked": "^5.1.2", "pagefind": "^1.1.1", "pinia": "^3.0.1", @@ -1274,6 +1276,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://mirrors.tencent.com/npm/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://mirrors.tencent.com/npm/@types/eslint/-/eslint-9.6.1.tgz", @@ -1346,6 +1357,12 @@ "resolved": "https://mirrors.tencent.com/npm/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://mirrors.tencent.com/npm/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.15.2", "resolved": "https://mirrors.tencent.com/npm/@types/validator/-/validator-13.15.2.tgz", @@ -2121,6 +2138,15 @@ "node": ">=0.4.0" } }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://mirrors.tencent.com/npm/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://mirrors.tencent.com/npm/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 64f264b39..de5aa2622 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,9 @@ }, "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", + "@types/dompurify": "^3.0.5", "axios": "^1.8.4", + "dompurify": "^3.2.6", "marked": "^5.1.2", "pagefind": "^1.1.1", "pinia": "^3.0.1", diff --git a/frontend/src/components/doc-content.vue b/frontend/src/components/doc-content.vue index 5c73e3553..283091a04 100644 --- a/frontend/src/components/doc-content.vue +++ b/frontend/src/components/doc-content.vue @@ -4,6 +4,8 @@ import { onMounted, ref, nextTick, onUnmounted, onUpdated, watch } from "vue"; import { downKnowledgeDetails } from "@/api/knowledge-base/index"; import { MessagePlugin } from "tdesign-vue-next"; import picturePreview from '@/components/picture-preview.vue'; +import { sanitizeHTML, safeMarkdownToHTML, createSafeImage, isValidImageURL } from '@/utils/security'; + marked.use({ mangle: false, headerIds: false, @@ -37,10 +39,16 @@ const checkImage = (url) => { }); }; renderer.image = function (href, title, text) { - // 自定义HTML结构,图片展示带标题 + // 安全地处理图片链接 + if (!isValidImageURL(href)) { + return `

无效的图片链接

`; + } + + // 使用安全的图片创建函数 + const safeImage = createSafeImage(href, text || '', title || ''); return `
- ${title} -
${text}
+ ${safeImage} +
${text || ''}
`; }; const props = defineProps(["visible", "details"]); @@ -66,14 +74,23 @@ watch(() => props.details.md, (newVal) => { deep: true }) -// 处理 Markdown 中的图片 +// 安全地处理 Markdown 内容 const processMarkdown = (markdownText) => { - // 自定义渲染器处理图片 + if (!markdownText || typeof markdownText !== 'string') { + return ''; + } + + // 首先对 Markdown 内容进行安全处理 + const safeMarkdown = safeMarkdownToHTML(markdownText); + + // 使用安全的渲染器 marked.use({ renderer }); - let html = marked.parse(markdownText); - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - return doc.body.innerHTML; + let html = marked.parse(safeMarkdown); + + // 使用 DOMPurify 进行最终的安全清理 + const sanitizedHTML = sanitizeHTML(html); + + return sanitizedHTML; }; const closePreImg = () => { reviewImg.value = false diff --git a/frontend/src/utils/security.ts b/frontend/src/utils/security.ts new file mode 100644 index 000000000..76c5cd111 --- /dev/null +++ b/frontend/src/utils/security.ts @@ -0,0 +1,207 @@ +/** + * 安全工具类 - 防止 XSS 攻击 + */ + +import DOMPurify from 'dompurify'; + +// 配置 DOMPurify 的安全策略 +const DOMPurifyConfig = { + // 允许的标签 + ALLOWED_TAGS: [ + 'p', 'br', 'strong', 'em', 'u', 's', 'del', 'ins', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', + 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'div', 'span', 'figure', 'figcaption' + ], + // 允许的属性 + ALLOWED_ATTR: [ + 'href', 'title', 'alt', 'src', 'class', 'id', 'style', + 'target', 'rel', 'width', 'height' + ], + // 允许的协议 + ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, + // 禁止的标签和属性 + FORBID_TAGS: ['script', 'object', 'embed', 'form', 'input', 'button'], + FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'], + // 其他安全配置 + KEEP_CONTENT: true, + RETURN_DOM: false, + RETURN_DOM_FRAGMENT: false, + RETURN_DOM_IMPORT: false, + SANITIZE_DOM: true, + SANITIZE_NAMED_PROPS: true, + WHOLE_DOCUMENT: false, + // 自定义钩子函数 + HOOKS: { + // 在清理前处理 + beforeSanitizeElements: (currentNode: Element) => { + // 移除所有 script 标签 + if (currentNode.tagName === 'SCRIPT') { + currentNode.remove(); + return null; + } + // 移除所有事件处理器 + const eventAttrs = ['onclick', 'onload', 'onerror', 'onmouseover', 'onfocus', 'onblur']; + eventAttrs.forEach(attr => { + if (currentNode.hasAttribute(attr)) { + currentNode.removeAttribute(attr); + } + }); + }, + // 在清理后处理 + afterSanitizeElements: (currentNode: Element) => { + // 确保所有链接都有 rel="noopener noreferrer" + if (currentNode.tagName === 'A') { + const href = currentNode.getAttribute('href'); + if (href && href.startsWith('http')) { + currentNode.setAttribute('rel', 'noopener noreferrer'); + currentNode.setAttribute('target', '_blank'); + } + } + // 确保所有图片都有 alt 属性 + if (currentNode.tagName === 'IMG') { + if (!currentNode.getAttribute('alt')) { + currentNode.setAttribute('alt', ''); + } + } + } + } +}; + +/** + * 安全地清理 HTML 内容 + * @param html 需要清理的 HTML 字符串 + * @returns 清理后的安全 HTML 字符串 + */ +export function sanitizeHTML(html: string): string { + if (!html || typeof html !== 'string') { + return ''; + } + + try { + return DOMPurify.sanitize(html, DOMPurifyConfig); + } catch (error) { + console.error('HTML sanitization failed:', error); + // 如果清理失败,返回转义的纯文本 + return escapeHTML(html); + } +} + +/** + * 转义 HTML 特殊字符 + * @param text 需要转义的文本 + * @returns 转义后的文本 + */ +export function escapeHTML(text: string): string { + if (!text || typeof text !== 'string') { + return ''; + } + + const map: { [key: string]: string } = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' + }; + + return text.replace(/[&<>"'`=\/]/g, (s) => map[s]); +} + +/** + * 验证 URL 是否安全 + * @param url 需要验证的 URL + * @returns 是否为安全 URL + */ +export function isValidURL(url: string): boolean { + if (!url || typeof url !== 'string') { + return false; + } + + try { + const urlObj = new URL(url); + // 只允许 http 和 https 协议 + return ['http:', 'https:'].includes(urlObj.protocol); + } catch { + return false; + } +} + +/** + * 安全地处理 Markdown 内容 + * @param markdown Markdown 文本 + * @returns 安全的 HTML 字符串 + */ +export function safeMarkdownToHTML(markdown: string): string { + if (!markdown || typeof markdown !== 'string') { + return ''; + } + + // 首先转义可能的 HTML 标签 + const escapedMarkdown = markdown + .replace(/)<[^<]*)*<\/script>/gi, '') + .replace(/)<[^<]*)*<\/iframe>/gi, '') + .replace(/)<[^<]*)*<\/object>/gi, '') + .replace(/)<[^<]*)*<\/embed>/gi, ''); + + return escapedMarkdown; +} + +/** + * 清理用户输入 + * @param input 用户输入 + * @returns 清理后的安全输入 + */ +export function sanitizeUserInput(input: string): string { + if (!input || typeof input !== 'string') { + return ''; + } + + // 移除控制字符 + let cleaned = input.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); + + // 限制长度 + if (cleaned.length > 10000) { + cleaned = cleaned.substring(0, 10000); + } + + return cleaned.trim(); +} + +/** + * 验证图片 URL 是否安全 + * @param url 图片 URL + * @returns 是否为安全的图片 URL + */ +export function isValidImageURL(url: string): boolean { + if (!isValidURL(url)) { + return false; + } + + // 检查是否为图片文件 + const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i; + return imageExtensions.test(url); +} + +/** + * 创建安全的图片元素 + * @param src 图片源 + * @param alt 替代文本 + * @param title 标题 + * @returns 安全的图片 HTML + */ +export function createSafeImage(src: string, alt: string = '', title: string = ''): string { + if (!isValidImageURL(src)) { + return ''; + } + + const safeSrc = escapeHTML(src); + const safeAlt = escapeHTML(alt); + const safeTitle = escapeHTML(title); + + return `${safeAlt}`; +} diff --git a/frontend/src/views/chat/components/botmsg.vue b/frontend/src/views/chat/components/botmsg.vue index e2b71750f..d476065fb 100644 --- a/frontend/src/views/chat/components/botmsg.vue +++ b/frontend/src/views/chat/components/botmsg.vue @@ -23,6 +23,8 @@ import { marked } from 'marked'; import docInfo from './docInfo.vue'; import deepThink from './deepThink.vue'; import picturePreview from '@/components/picture-preview.vue'; +import { sanitizeHTML, safeMarkdownToHTML, createSafeImage, isValidImageURL } from '@/utils/security'; + marked.use({ mangle: false, headerIds: false, @@ -89,36 +91,36 @@ const checkImage = (url) => { img.src = url; }); }; -// 处理 Markdown 中的图片 +// 安全地处理 Markdown 内容 const processMarkdown = (markdownText) => { - // 自定义渲染器处理图片 + if (!markdownText || typeof markdownText !== 'string') { + return ''; + } + + // 首先对 Markdown 内容进行安全处理 + const safeMarkdown = safeMarkdownToHTML(markdownText); + + // 自定义安全的渲染器处理图片 const renderer = { image(href, title, text) { - return `${text}`; + // 验证图片 URL 是否安全 + if (!isValidImageURL(href)) { + return `

无效的图片链接

`; + } + // 使用安全的图片创建函数 + return createSafeImage(href, text || '', title || ''); } }; marked.use({ renderer }); - // 第一次渲染 - let html = marked.parse(markdownText); + // 安全地渲染 Markdown + let html = marked.parse(safeMarkdown); - // 创建虚拟 DOM 来操作 - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - - // 检查所有图片 - // const images = doc.querySelectorAll('img'); - // images.forEach(async item => { - // const isValid = await checkImage(item.src); - // if (!isValid) { - // item.remove(); - // } - // }); - // if (props.isFirstEnter) { - // emit('scroll-bottom') - // } - return doc.body.innerHTML; + // 使用 DOMPurify 进行最终的安全清理 + const sanitizedHTML = sanitizeHTML(html); + + return sanitizedHTML; }; const handleImg = async (newVal) => { let index = newVal.lastIndexOf('!['); diff --git a/frontend/src/views/chat/components/deepThink.vue b/frontend/src/views/chat/components/deepThink.vue index 1de12c380..49e0f2b81 100644 --- a/frontend/src/views/chat/components/deepThink.vue +++ b/frontend/src/views/chat/components/deepThink.vue @@ -29,6 +29,7 @@