fix(系统): 解决xss前端问题

This commit is contained in:
Jamin
2026-08-13 09:34:58 +08:00
parent 6238658b20
commit a66e9e1441
16 changed files with 421 additions and 21 deletions

View File

@@ -4,6 +4,8 @@
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; base-uri 'self'; object-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://api.map.baidu.com https://*.bdimg.com https://*.baidu.com; style-src 'self' 'unsafe-inline' https://*.bdimg.com https://*.baidu.com; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; frame-src 'self' http: https:; worker-src 'self' blob:; media-src 'self' blob: http: https:;">
<meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">

View File

@@ -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";

View File

@@ -0,0 +1,30 @@
<template>
<component :is="inline ? 'span' : 'div'" v-html="safeHtml"></component>
</template>
<script>
import { sanitizeDisplayHtml, sanitizeRichText } from '@/utils/security';
export default {
name: 'SafeHtml',
props: {
html: {
type: [String, Number],
default: '',
},
mode: {
type: String,
default: 'display',
},
inline: {
type: Boolean,
default: false,
},
},
computed: {
safeHtml() {
return this.mode === 'richText' ? sanitizeRichText(this.html) : sanitizeDisplayHtml(this.html);
},
},
};
</script>

View File

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

View File

@@ -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
}

280
vue/src/utils/security.js Normal file
View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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));
}

View File

@@ -211,7 +211,7 @@
<span style="margin-left: 20px">{{ notice.createTime }}</span>
</div>
<div v-loading="loading" class="content">
<div v-html="notice.noticeContent"></div>
<safe-html :html="notice.noticeContent" mode="richText"></safe-html>
</div>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="closeDetail">{{ $t('close') }}</el-button>

View File

@@ -38,7 +38,7 @@
</el-table-column>
<el-table-column :label="$t('speaker.clientDetails.index.893021-14')" align="center" prop="authorizedGrantTypes">
<template slot-scope="scope">
<div v-html="formatGrantTypesDisplay(scope.row.authorizedGrantTypes)"></div>
<safe-html :html="formatGrantTypesDisplay(scope.row.authorizedGrantTypes)"></safe-html>
</template>
</el-table-column>
<el-table-column :label="$t('speaker.clientDetails.index.893021-15')" align="center" prop="webServerRedirectUri" min-width="130" />

View File

@@ -48,7 +48,7 @@
<el-table-column :label="$t('device.device-log.798283-2')" align="center" prop="identify" />
<el-table-column :label="$t('device.device-log.798283-15')" align="left" header-align="center" prop="logValue">
<template slot-scope="scope">
<div v-html="formatValueDisplay(scope.row)"></div>
<safe-html :html="formatValueDisplay(scope.row)"></safe-html>
</template>
</el-table-column>

View File

@@ -25,13 +25,13 @@
<el-table-column :label="$t('device.device-timer.433369-7')" align="center" prop="jobName" :show-overflow-tooltip="true" />
<el-table-column :label="$t('device.device-timer.433369-8')" align="center" prop="cronText">
<template slot-scope="scope">
<div v-html="formatCronDisplay(scope.row)"></div>
<safe-html :html="formatCronDisplay(scope.row)"></safe-html>
</template>
</el-table-column>
<el-table-column :label="$t('device.device-timer.433369-9')" align="center" prop="cronExpression" :show-overflow-tooltip="true" />
<el-table-column :label="$t('device.device-timer.433369-10')" align="left" prop="actions" :show-overflow-tooltip="true">
<template slot-scope="scope">
<div v-html="formatActionsDisplay(scope.row.actions)" style="overflow: hidden; white-space: nowrap"></div>
<safe-html :html="formatActionsDisplay(scope.row.actions)" style="overflow: hidden; white-space: nowrap"></safe-html>
</template>
</el-table-column>
@@ -222,8 +222,8 @@
<el-col :span="24">
<el-form-item :label="$t('device.device-timer.433369-52')">
<div v-html="formatActionsDisplay(form.actions)"
style="border: 1px solid #ddd; padding: 10px; border-radius: 5px; width: 465px"></div>
<safe-html :html="formatActionsDisplay(form.actions)"
style="border: 1px solid #ddd; padding: 10px; border-radius: 5px; width: 465px"></safe-html>
</el-form-item>
</el-col>
</el-row>

View File

@@ -177,6 +177,24 @@
<script>
import { listLog, getLog, delLog, addLog, updateLog } from '@/api/iot/log';
import { hasXssRisk } from '@/utils/security';
const XSS_CHECK_FIELDS = [
'logName',
'logLevel',
'deviceId',
'deviceName',
'userId',
'userName',
'tenantId',
'tenantName',
'triggerSource',
'isAlert',
'remark',
'logValue',
'istop',
'ismonitor',
];
export default {
name: 'Log',
@@ -367,6 +385,10 @@ export default {
submitForm() {
this.$refs['form'].validate((valid) => {
if (valid) {
if (this.hasLogXssRisk()) {
this.$modal.msgError('日志内容包含疑似XSS脚本特征请修改后再提交');
return;
}
if (this.form.deviceLogId != null) {
updateLog(this.form).then((response) => {
this.$modal.msgSuccess(this.$t('iot.group.index.637432-24'));
@@ -383,6 +405,9 @@ export default {
}
});
},
hasLogXssRisk() {
return XSS_CHECK_FIELDS.some((field) => hasXssRisk(this.form[field]));
},
/** 删除按钮操作 */
handleDelete(row) {
const deviceLogIds = row.deviceLogId || this.ids;

View File

@@ -168,7 +168,7 @@
<span style="margin-left: 20px">{{ form.createTime }}</span>
</div>
<div v-loading="loadingDetail" class="content">
<div v-html="form.content"></div>
<safe-html :html="form.content" mode="richText"></safe-html>
</div>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="closeDetail">{{ $t('device.device-edit.148398-57') }}</el-button>
@@ -182,6 +182,7 @@
import { listNews, getNews, delNews, addNews, updateNews } from '@/api/iot/news';
import { listShortNewsCategory } from '@/api/iot/newsCategory';
import imageUpload from '../../../components/ImageUpload/index';
import { sanitizeRichText } from '@/utils/security';
export default {
name: 'News',
@@ -363,6 +364,7 @@ export default {
}
this.$refs['form'].validate((valid) => {
if (valid) {
this.form.content = sanitizeRichText(this.form.content);
if (this.form.newsId != null) {
updateNews(this.form).then((response) => {
this.$modal.msgSuccess(this.$t('iot.group.index.637432-24'));

View File

@@ -53,7 +53,7 @@
</el-table-column>
<el-table-column :label="$t('product.product-things-model.142341-18')" align="left" header-align="center" prop="specs" min-width="150" class-name="specsColor">
<template slot-scope="scope">
<div v-html="formatSpecsDisplay(scope.row.specs)"></div>
<safe-html :html="formatSpecsDisplay(scope.row.specs)"></safe-html>
</template>
</el-table-column>
<el-table-column :label="$t('product.product-things-model.142341-19')" align="center" prop="formula" />

View File

@@ -55,7 +55,7 @@
</el-table-column>
<el-table-column :label="$t('template.index.891112-15')" align="left" header-align="center" prop="specs" min-width="150" class-name="specsColor">
<template slot-scope="scope">
<div v-html="formatSpecsDisplay(scope.row.specs)"></div>
<safe-html :html="formatSpecsDisplay(scope.row.specs)"></safe-html>
</template>
</el-table-column>
<el-table-column :label="$t('template.index.891112-16')" align="center" prop="modelOrder" width="80" />

View File

@@ -107,6 +107,7 @@
<script>
import { listNotice, getNotice, delNotice, addNotice, updateNotice } from '@/api/system/notice';
import { sanitizeRichText } from '@/utils/security';
export default {
name: 'Notice',
@@ -213,6 +214,7 @@ export default {
submitForm: function () {
this.$refs['form'].validate((valid) => {
if (valid) {
this.form.noticeContent = sanitizeRichText(this.form.noticeContent);
if (this.form.noticeId != undefined) {
updateNotice(this.form).then((response) => {
this.$modal.msgSuccess(this.$t('updateSuccess'));

View File

@@ -78,7 +78,7 @@
<el-tabs v-model="preview.activeName">
<el-tab-pane v-for="(value, key) in preview.data" :label="key.substring(key.lastIndexOf('/') + 1, key.indexOf('.vm'))" :name="key.substring(key.lastIndexOf('/') + 1, key.indexOf('.vm'))" :key="key">
<el-link :underline="false" icon="el-icon-document-copy" v-clipboard:copy="value" v-clipboard:success="clipboardSuccess" style="float: right">{{ $t('device.device-edit.148398-55') }}</el-link>
<pre><code class="hljs" v-html="highlightedCode(value, key)"></code></pre>
<pre><code class="hljs"><safe-html :html="highlightedCode(value, key)" inline></safe-html></code></pre>
</el-tab-pane>
</el-tabs>
</el-dialog>