update 优化 富文本内容展示增加xss拦截过滤

This commit is contained in:
疯狂的狮子Li
2026-03-27 17:02:17 +08:00
parent 87b56a3823
commit 87c09d0917
2 changed files with 201 additions and 1 deletions
+197
View File
@@ -0,0 +1,197 @@
const DEFAULT_ALLOWED_TAGS = new Set([
'a',
'article',
'b',
'blockquote',
'br',
'caption',
'code',
'col',
'colgroup',
'del',
'div',
'em',
'figcaption',
'figure',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'li',
'ol',
'p',
'pre',
's',
'section',
'span',
'strong',
'sub',
'sup',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'u',
'ul',
'video',
'source'
]);
const URI_ATTRS = new Set(['href', 'src']);
const GLOBAL_ALLOWED_ATTRS = new Set(['class', 'title', 'alt', 'style']);
const TAG_ALLOWED_ATTRS: Record<string, Set<string>> = {
a: new Set(['href', 'target', 'rel']),
img: new Set(['src', 'alt', 'title']),
video: new Set(['src', 'poster', 'controls', 'autoplay', 'muted', 'loop']),
source: new Set(['src', 'type']),
td: new Set(['colspan', 'rowspan']),
th: new Set(['colspan', 'rowspan']),
col: new Set(['span']),
colgroup: new Set(['span'])
};
const DANGEROUS_TAGS = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta', 'base', 'form', 'input', 'button', 'textarea', 'select', 'option', 'svg', 'math']);
const SAFE_URL_PATTERN = /^(https?:|mailto:|tel:|\/|#|data:image\/(?:png|jpeg|jpg|gif|webp);base64,)/i;
const ALLOWED_STYLE_PROPS = new Set([
'background-color',
'color',
'font-family',
'font-size',
'font-style',
'font-weight',
'height',
'line-height',
'list-style-type',
'margin-left',
'max-width',
'min-width',
'padding-left',
'text-align',
'text-decoration',
'text-indent',
'vertical-align',
'white-space',
'width'
]);
const UNSAFE_STYLE_VALUE_PATTERN = /(?:expression\s*\(|url\s*\(|javascript:|vbscript:|data:|@import|behavior:)/i;
const sanitizeUrl = (value: string) => {
const normalized = value.trim();
if (!normalized) {
return '';
}
return SAFE_URL_PATTERN.test(normalized) ? normalized : '';
};
const sanitizeStyle = (value: string) => {
const declarations = value
.split(';')
.map((item) => item.trim())
.filter(Boolean);
const safeDeclarations = declarations.flatMap((declaration) => {
const separatorIndex = declaration.indexOf(':');
if (separatorIndex <= 0) {
return [];
}
const property = declaration.slice(0, separatorIndex).trim().toLowerCase();
const propertyValue = declaration.slice(separatorIndex + 1).trim();
if (!ALLOWED_STYLE_PROPS.has(property) || !propertyValue || UNSAFE_STYLE_VALUE_PATTERN.test(propertyValue)) {
return [];
}
return [`${property}: ${propertyValue}`];
});
return safeDeclarations.join('; ');
};
const shouldKeepAttr = (tagName: string, attrName: string) => {
if (attrName.startsWith('on')) {
return false;
}
if (GLOBAL_ALLOWED_ATTRS.has(attrName)) {
return true;
}
return TAG_ALLOWED_ATTRS[tagName]?.has(attrName) ?? false;
};
const sanitizeElement = (element: Element) => {
const tagName = element.tagName.toLowerCase();
if (DANGEROUS_TAGS.has(tagName)) {
element.remove();
return;
}
if (!DEFAULT_ALLOWED_TAGS.has(tagName)) {
const parent = element.parentNode;
if (!parent) {
element.remove();
return;
}
while (element.firstChild) {
parent.insertBefore(element.firstChild, element);
}
parent.removeChild(element);
return;
}
Array.from(element.attributes).forEach((attr) => {
const attrName = attr.name.toLowerCase();
if (!shouldKeepAttr(tagName, attrName)) {
element.removeAttribute(attr.name);
return;
}
if (attrName === 'style') {
const sanitizedStyle = sanitizeStyle(attr.value);
if (!sanitizedStyle) {
element.removeAttribute(attr.name);
return;
}
element.setAttribute(attr.name, sanitizedStyle);
return;
}
if (URI_ATTRS.has(attrName)) {
const sanitized = sanitizeUrl(attr.value);
if (!sanitized) {
element.removeAttribute(attr.name);
return;
}
element.setAttribute(attr.name, sanitized);
}
});
if (tagName === 'a' && element.hasAttribute('target')) {
element.setAttribute('rel', 'noopener noreferrer');
}
};
export function sanitizeHtml(html?: string) {
if (!html) {
return '';
}
const template = document.createElement('template');
template.innerHTML = html;
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT);
const elements: Element[] = [];
while (walker.nextNode()) {
elements.push(walker.currentNode as Element);
}
elements.forEach(sanitizeElement);
return template.innerHTML;
}
+4 -1
View File
@@ -150,7 +150,7 @@
</div> </div>
</div> </div>
<el-divider /> <el-divider />
<div class="notice-detail__content" v-html="detailForm.noticeContent || '<p>暂无公告内容</p>'"></div> <div class="notice-detail__content" v-html="safeNoticeContent"></div>
</div> </div>
</el-dialog> </el-dialog>
</div> </div>
@@ -159,6 +159,7 @@
<script setup name="Notice" lang="ts"> <script setup name="Notice" lang="ts">
import { listNotice, getNotice, delNotice, addNotice, updateNotice } from '@/api/system/notice'; import { listNotice, getNotice, delNotice, addNotice, updateNotice } from '@/api/system/notice';
import { NoticeForm, NoticeQuery, NoticeVO } from '@/api/system/notice/types'; import { NoticeForm, NoticeQuery, NoticeVO } from '@/api/system/notice/types';
import { sanitizeHtml } from '@/utils/sanitize';
const { proxy } = getCurrentInstance() as ComponentInternalInstance; const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const { sys_notice_status, sys_notice_type } = toRefs<any>(proxy?.useDict('sys_notice_status', 'sys_notice_type')); const { sys_notice_status, sys_notice_type } = toRefs<any>(proxy?.useDict('sys_notice_status', 'sys_notice_type'));
@@ -184,6 +185,7 @@ const detailDialog = reactive({
visible: false visible: false
}); });
const routeDetailSyncing = ref(false); const routeDetailSyncing = ref(false);
const emptyNoticeContent = '<p>暂无公告内容</p>';
const initFormData: NoticeForm = { const initFormData: NoticeForm = {
noticeId: undefined, noticeId: undefined,
@@ -195,6 +197,7 @@ const initFormData: NoticeForm = {
createByName: '' createByName: ''
}; };
const detailForm = ref<NoticeVO>({} as NoticeVO); const detailForm = ref<NoticeVO>({} as NoticeVO);
const safeNoticeContent = computed(() => sanitizeHtml(detailForm.value.noticeContent || emptyNoticeContent));
const data = reactive<PageData<NoticeForm, NoticeQuery>>({ const data = reactive<PageData<NoticeForm, NoticeQuery>>({
form: { ...initFormData }, form: { ...initFormData },
queryParams: { queryParams: {