update 优化 富文本内容展示增加xss拦截过滤
This commit is contained in:
197
src/utils/sanitize.ts
Normal file
197
src/utils/sanitize.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user