From a66e9e14417965c956679056570ba535d25e5a2c Mon Sep 17 00:00:00 2001
From: Jamin <839778985@qq.com>
Date: Thu, 13 Aug 2026 09:34:58 +0800
Subject: [PATCH] =?UTF-8?q?fix(=E7=B3=BB=E7=BB=9F):=20=E8=A7=A3=E5=86=B3xs?=
=?UTF-8?q?s=E5=89=8D=E7=AB=AF=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
vue/public/index.html | 2 +
vue/src/components/Editor/index.vue | 73 ++++-
vue/src/components/SafeHtml/index.vue | 30 ++
vue/src/main.js | 3 +
vue/src/utils/index.js | 3 +-
vue/src/utils/security.js | 280 ++++++++++++++++++
vue/src/views/index.vue | 2 +-
vue/src/views/iot/clientDetails/index.vue | 2 +-
vue/src/views/iot/device/device-log.vue | 2 +-
vue/src/views/iot/device/device-timer.vue | 8 +-
vue/src/views/iot/log/index.vue | 25 ++
vue/src/views/iot/news/index.vue | 4 +-
.../iot/product/product-things-model.vue | 2 +-
vue/src/views/iot/template/index.vue | 2 +-
vue/src/views/system/notice/index.vue | 2 +
vue/src/views/tool/gen/index.vue | 2 +-
16 files changed, 421 insertions(+), 21 deletions(-)
create mode 100644 vue/src/components/SafeHtml/index.vue
create mode 100644 vue/src/utils/security.js
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 }}