diff --git a/vue/public/index.html b/vue/public/index.html index 9e24e1ce..3ce79da8 100644 --- a/vue/public/index.html +++ b/vue/public/index.html @@ -4,6 +4,8 @@ + diff --git a/vue/src/components/Editor/index.vue b/vue/src/components/Editor/index.vue index 6bb5a18d..f283b42d 100644 --- a/vue/src/components/Editor/index.vue +++ b/vue/src/components/Editor/index.vue @@ -23,6 +23,7 @@ import "quill/dist/quill.core.css"; import "quill/dist/quill.snow.css"; import "quill/dist/quill.bubble.css"; import { getToken } from "@/utils/auth"; +import { escapeHtml, sanitizeRichText } from "@/utils/security"; export default { name: "Editor", @@ -66,6 +67,7 @@ export default { }, Quill: null, currentValue: "", + sanitizing: false, options: { theme: "snow", bounds: document.body, @@ -82,9 +84,26 @@ export default { [{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色 [{ align: [] }], // 对齐方式 ["clean"], // 清除文本格式 - ["link", "image", "video"] // 链接、图片、视频 + ["link", "image"] // 链接、图片 ], }, + formats: [ + "align", + "background", + "blockquote", + "bold", + "code-block", + "color", + "header", + "image", + "indent", + "italic", + "link", + "list", + "size", + "strike", + "underline", + ], placeholder: "请输入内容", readOnly: this.readOnly, }, @@ -106,9 +125,9 @@ export default { value: { handler(val) { if (val !== this.currentValue) { - this.currentValue = val === null ? "" : val; + this.currentValue = sanitizeRichText(val === null ? "" : val); if (this.Quill) { - this.Quill.pasteHTML(this.currentValue); + this.setEditorHtml(this.currentValue); } } }, @@ -119,6 +138,9 @@ export default { this.init(); }, beforeDestroy() { + if (this.Quill && this.Quill.root) { + this.Quill.root.removeEventListener("paste", this.handlePaste, true); + } this.Quill = null; }, methods: { @@ -137,11 +159,16 @@ export default { } }); } - this.Quill.pasteHTML(this.currentValue); + this.Quill.root.addEventListener("paste", this.handlePaste, true); + this.setEditorHtml(this.currentValue); this.Quill.on("text-change", (delta, oldDelta, source) => { - const html = this.$refs.editor.children[0].innerHTML; + const rawHtml = this.$refs.editor.children[0].innerHTML; + const html = sanitizeRichText(rawHtml); const text = this.Quill.getText(); const quill = this.Quill; + if (rawHtml !== html && !this.sanitizing) { + this.setEditorHtml(html); + } this.currentValue = html; this.$emit("input", html); this.$emit("on-change", { html, text, quill }); @@ -156,6 +183,38 @@ export default { this.$emit("on-editor-change", eventName, ...args); }); }, + setEditorHtml(value) { + if (!this.Quill) { + return; + } + this.sanitizing = true; + this.Quill.pasteHTML(sanitizeRichText(value)); + this.$nextTick(() => { + this.sanitizing = false; + }); + }, + handlePaste(event) { + const clipboardData = event.clipboardData || window.clipboardData; + if (!clipboardData) { + return; + } + const html = clipboardData.getData("text/html"); + if (!html) { + return; + } + event.preventDefault(); + const text = clipboardData.getData("text/plain"); + const safeHtml = sanitizeRichText(html) || escapeHtml(text); + const range = this.Quill.getSelection(true); + const index = range ? range.index : this.Quill.getLength(); + if (range && range.length) { + this.Quill.deleteText(range.index, range.length, "user"); + } + const beforeLength = this.Quill.getLength(); + this.Quill.clipboard.dangerouslyPasteHTML(index, safeHtml, "user"); + const pasteLength = Math.max(this.Quill.getLength() - beforeLength, 0); + this.Quill.setSelection(index + pasteLength, 0, "user"); + }, // 上传前校检格式和大小 handleBeforeUpload(file) { // 校检文件大小 @@ -207,10 +266,6 @@ export default { padding-right: 0px; } -.ql-snow .ql-tooltip[data-mode="video"]::before { - content: "请输入视频地址:"; -} - .ql-snow .ql-picker.ql-size .ql-picker-label::before, .ql-snow .ql-picker.ql-size .ql-picker-item::before { content: "14px"; diff --git a/vue/src/components/SafeHtml/index.vue b/vue/src/components/SafeHtml/index.vue new file mode 100644 index 00000000..8e13e3da --- /dev/null +++ b/vue/src/components/SafeHtml/index.vue @@ -0,0 +1,30 @@ + + + diff --git a/vue/src/main.js b/vue/src/main.js index a151c864..46388010 100644 --- a/vue/src/main.js +++ b/vue/src/main.js @@ -27,6 +27,8 @@ import Pagination from '@/components/Pagination'; import RightToolbar from '@/components/RightToolbar'; // 富文本组件 import Editor from '@/components/Editor'; +// 安全HTML渲染组件 +import SafeHtml from '@/components/SafeHtml'; // 文件上传组件 import FileUpload from '@/components/FileUpload'; // 图片上传组件 @@ -88,6 +90,7 @@ Vue.component('DictTag', DictTag); Vue.component('Pagination', Pagination); Vue.component('RightToolbar', RightToolbar); Vue.component('Editor', Editor); +Vue.component('SafeHtml', SafeHtml); Vue.component('FileUpload', FileUpload); Vue.component('ImageUpload', ImageUpload); Vue.component('ImagePreview', ImagePreview); diff --git a/vue/src/utils/index.js b/vue/src/utils/index.js index 8b4d0a8b..bf4aaafb 100644 --- a/vue/src/utils/index.js +++ b/vue/src/utils/index.js @@ -1,4 +1,5 @@ import { parseTime } from './ruoyi' +import { sanitizeDisplayHtml } from './security' /** * 表格时间格式化 @@ -149,7 +150,7 @@ export function param2Obj(url) { */ export function html2Text(val) { const div = document.createElement('div') - div.innerHTML = val + div.innerHTML = sanitizeDisplayHtml(val) return div.textContent || div.innerText } diff --git a/vue/src/utils/security.js b/vue/src/utils/security.js new file mode 100644 index 00000000..c68c147a --- /dev/null +++ b/vue/src/utils/security.js @@ -0,0 +1,280 @@ +const DANGEROUS_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'svg', 'math']; + +const RICH_TEXT_ALLOWED_TAGS = [ + 'a', + 'b', + 'blockquote', + 'br', + 'code', + 'div', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'i', + 'img', + 'li', + 'ol', + 'p', + 'pre', + 's', + 'span', + 'strong', + 'u', + 'ul', +]; + +const DISPLAY_ALLOWED_TAGS = ['b', 'br', 'code', 'div', 'em', 'i', 'pre', 's', 'span', 'strong', 'u']; + +const GLOBAL_ATTRS = ['class', 'style', 'title']; + +const TAG_ATTRS = { + a: ['href', 'target', 'rel'], + img: ['src', 'alt', 'width', 'height'], +}; + +const ALLOWED_STYLE_PROPS = [ + 'background-color', + 'border', + 'border-radius', + 'color', + 'display', + 'font-size', + 'font-style', + 'font-weight', + 'height', + 'line-height', + 'margin', + 'margin-bottom', + 'margin-left', + 'margin-right', + 'margin-top', + 'max-width', + 'min-width', + 'overflow', + 'padding', + 'text-align', + 'text-decoration', + 'white-space', + 'width', +]; + +const XSS_RISK_PATTERNS = [ + /<\s*script\b/i, + /<\s*iframe\b/i, + /<\s*object\b/i, + /<\s*embed\b/i, + /<\s*svg\b/i, + /<\s*math\b/i, + /\bon[a-z]+\s*=/i, + /javascript\s*:/i, + /vbscript\s*:/i, + /data\s*:\s*text\/html/i, + /expression\s*\(/i, + /url\s*\(\s*['"]?\s*javascript\s*:/i, +]; + +function includes(list, value) { + return list.indexOf(value) !== -1; +} + +function decodeHtmlEntities(value) { + if (typeof document === 'undefined') { + return value; + } + const textarea = document.createElement('textarea'); + textarea.innerHTML = value; + return textarea.value; +} + +function normalizeForScan(value) { + let text = String(value); + try { + text = decodeURIComponent(text); + } catch (e) { + text = String(value); + } + return decodeHtmlEntities(text) + .replace(/[\u0000-\u001F\u007F]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function isSafeUrl(value, allowImageData) { + if (!value) { + return false; + } + const url = normalizeForScan(value).replace(/[\u0000-\u001F\u007F\s]/g, ''); + const lowerUrl = url.toLowerCase(); + + if (/^(javascript|vbscript):/i.test(lowerUrl)) { + return false; + } + if (/^data:/i.test(lowerUrl)) { + return allowImageData && /^data:image\/(png|jpe?g|gif|webp);base64,/i.test(lowerUrl); + } + if (/^[a-z][a-z0-9+.-]*:/i.test(lowerUrl)) { + return /^(https?|mailto|tel):/i.test(lowerUrl); + } + return true; +} + +function sanitizeStyle(styleText) { + return String(styleText) + .split(';') + .map((item) => { + const index = item.indexOf(':'); + if (index === -1) { + return ''; + } + const name = item.substring(0, index).trim().toLowerCase(); + const value = item.substring(index + 1).trim(); + if (!includes(ALLOWED_STYLE_PROPS, name)) { + return ''; + } + if (/expression\s*\(|url\s*\(|javascript\s*:|vbscript\s*:|data\s*:/i.test(value)) { + return ''; + } + if (!/^[#(),.%\w\s-]+$/.test(value)) { + return ''; + } + return `${name}: ${value}`; + }) + .filter(Boolean) + .join('; '); +} + +function sanitizeClass(classText) { + return String(classText) + .split(/\s+/) + .filter((className) => /^(ql-|hljs|language-)[\w-]*$/.test(className)) + .join(' '); +} + +function isAllowedAttr(tagName, attrName) { + return includes(GLOBAL_ATTRS, attrName) || (TAG_ATTRS[tagName] && includes(TAG_ATTRS[tagName], attrName)); +} + +function unwrapNode(node) { + const parent = node.parentNode; + if (!parent) { + return; + } + while (node.firstChild) { + parent.insertBefore(node.firstChild, node); + } + parent.removeChild(node); +} + +function sanitizeElement(node, allowedTags) { + const tagName = node.nodeName.toLowerCase(); + if (includes(DANGEROUS_TAGS, tagName)) { + node.parentNode.removeChild(node); + return; + } + if (!includes(allowedTags, tagName)) { + sanitizeChildren(node, allowedTags); + unwrapNode(node); + return; + } + + Array.from(node.attributes).forEach((attr) => { + const attrName = attr.name.toLowerCase(); + const attrValue = attr.value; + if (/^on/i.test(attrName) || !isAllowedAttr(tagName, attrName)) { + node.removeAttribute(attr.name); + return; + } + if ((attrName === 'href' || attrName === 'src') && !isSafeUrl(attrValue, tagName === 'img')) { + node.removeAttribute(attr.name); + return; + } + if (attrName === 'style') { + const safeStyle = sanitizeStyle(attrValue); + if (safeStyle) { + node.setAttribute('style', safeStyle); + } else { + node.removeAttribute('style'); + } + return; + } + if (attrName === 'class') { + const safeClass = sanitizeClass(attrValue); + if (safeClass) { + node.setAttribute('class', safeClass); + } else { + node.removeAttribute('class'); + } + return; + } + if (tagName === 'a' && attrName === 'target') { + if (attrValue === '_blank') { + node.setAttribute('rel', 'noopener noreferrer'); + } else { + node.removeAttribute(attr.name); + } + } + }); + + sanitizeChildren(node, allowedTags); +} + +function sanitizeChildren(parent, allowedTags) { + Array.from(parent.childNodes).forEach((node) => { + if (node.nodeType === 1) { + sanitizeElement(node, allowedTags); + } else if (node.nodeType !== 3) { + node.parentNode.removeChild(node); + } + }); +} + +function sanitizeHtml(html, allowedTags) { + if (html === null || html === undefined) { + return ''; + } + if (typeof document === 'undefined') { + return escapeHtml(html); + } + const template = document.createElement('template'); + template.innerHTML = String(html); + sanitizeChildren(template.content || template, allowedTags); + return template.innerHTML; +} + +export function escapeHtml(value) { + if (value === null || value === undefined) { + return ''; + } + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function sanitizeRichText(html) { + return sanitizeHtml(html, RICH_TEXT_ALLOWED_TAGS); +} + +export function sanitizeDisplayHtml(html) { + return sanitizeHtml(html, DISPLAY_ALLOWED_TAGS); +} + +export function hasXssRisk(value) { + if (value === null || value === undefined) { + return false; + } + if (Array.isArray(value)) { + return value.some((item) => hasXssRisk(item)); + } + if (typeof value === 'object') { + return Object.keys(value).some((key) => hasXssRisk(value[key])); + } + const text = normalizeForScan(value); + return XSS_RISK_PATTERNS.some((pattern) => pattern.test(text)); +} diff --git a/vue/src/views/index.vue b/vue/src/views/index.vue index d4046f67..42a0cc62 100644 --- a/vue/src/views/index.vue +++ b/vue/src/views/index.vue @@ -211,7 +211,7 @@ {{ notice.createTime }}
-
+