update 优化 富文本内容展示增加xss拦截过滤
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user