feat(auth): 后台登录、角色权限与会话访问策略
管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
1 parent
5e0615f36d
commit
e24d3e2a65
123 files changed
+25293
No files matched your search
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
// Register DOMPurify hooks once on module load to prevent duplicate registrations
|
||||
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
|
||||
// Block SVG elements entirely
|
||||
if (data.tagName === 'svg') {
|
||||
node.parentNode?.removeChild(node)
|
||||
return
|
||||
}
|
||||
|
||||
// Block math elements
|
||||
if (data.tagName === 'math') {
|
||||
node.parentNode?.removeChild(node)
|
||||
return
|
||||
}
|
||||
|
||||
// Block any foreign objects
|
||||
if (data.tagName === 'foreignobject') {
|
||||
node.parentNode?.removeChild(node)
|
||||
return
|
||||
}
|
||||
|
||||
// SECURITY: Remove <img>, <area>, and <map> tags completely. Anchors are kept
|
||||
// (clickable links) but hardened in afterSanitizeAttributes: protocol-restricted
|
||||
// to http(s)/mailto and forced target="_blank" rel="noopener noreferrer nofollow".
|
||||
const element = node as HTMLElement
|
||||
const tagName = data.tagName?.toUpperCase()
|
||||
if (tagName === 'IMG' || tagName === 'AREA' || tagName === 'MAP') {
|
||||
element.parentNode?.removeChild(element)
|
||||
}
|
||||
})
|
||||
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
// Block javascript: URIs (including encoded variants)
|
||||
if (node.hasAttribute('href')) {
|
||||
const href = node.getAttribute('href') || ''
|
||||
try {
|
||||
const decodedHref = decodeURIComponent(href.toLowerCase())
|
||||
|
||||
if (
|
||||
decodedHref.includes('javascript:') ||
|
||||
decodedHref.includes('data:text/html') ||
|
||||
decodedHref.includes('vbscript:') ||
|
||||
decodedHref.includes('about:') ||
|
||||
decodedHref.includes('file:')
|
||||
) {
|
||||
node.removeAttribute('href')
|
||||
}
|
||||
} catch {
|
||||
// If decoding fails, use original value for comparison
|
||||
if (
|
||||
href.toLowerCase().includes('javascript:') ||
|
||||
href.toLowerCase().includes('data:text/html') ||
|
||||
href.toLowerCase().includes('vbscript:') ||
|
||||
href.toLowerCase().includes('about:') ||
|
||||
href.toLowerCase().includes('file:')
|
||||
) {
|
||||
node.removeAttribute('href')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Harden anchors so markdown links render clickable but safely: positively allow
|
||||
// only http(s)/mailto, and always open in a new tab with noopener.
|
||||
if (node.nodeName === 'A') {
|
||||
const href = (node.getAttribute('href') || '').trim()
|
||||
if (!/^(https?:|mailto:)/i.test(href)) {
|
||||
node.removeAttribute('href')
|
||||
} else {
|
||||
node.setAttribute('target', '_blank')
|
||||
node.setAttribute('rel', 'noopener noreferrer nofollow')
|
||||
}
|
||||
}
|
||||
|
||||
// Block javascript: and malicious data: URIs in src
|
||||
if (node.hasAttribute('src')) {
|
||||
const src = node.getAttribute('src') || ''
|
||||
try {
|
||||
const decodedSrc = decodeURIComponent(src.toLowerCase())
|
||||
|
||||
if (
|
||||
decodedSrc.includes('javascript:') ||
|
||||
decodedSrc.includes('data:text/html') ||
|
||||
decodedSrc.includes('vbscript:') ||
|
||||
decodedSrc.includes('about:') ||
|
||||
decodedSrc.includes('file:')
|
||||
) {
|
||||
node.removeAttribute('src')
|
||||
}
|
||||
} catch {
|
||||
// If decoding fails, use original value for comparison
|
||||
if (
|
||||
src.toLowerCase().includes('javascript:') ||
|
||||
src.toLowerCase().includes('data:text/html') ||
|
||||
src.toLowerCase().includes('vbscript:') ||
|
||||
src.toLowerCase().includes('about:') ||
|
||||
src.toLowerCase().includes('file:')
|
||||
) {
|
||||
node.removeAttribute('src')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CSS expressions in style attribute
|
||||
if (node.hasAttribute('style')) {
|
||||
const style = node.getAttribute('style') || ''
|
||||
try {
|
||||
const decodedStyle = decodeURIComponent(style.toLowerCase())
|
||||
|
||||
if (
|
||||
decodedStyle.includes('expression(') ||
|
||||
decodedStyle.includes('behavior:') ||
|
||||
decodedStyle.includes('-moz-binding') ||
|
||||
decodedStyle.includes('import') ||
|
||||
decodedStyle.includes('javascript:') ||
|
||||
decodedStyle.includes('vbscript:')
|
||||
) {
|
||||
node.removeAttribute('style')
|
||||
}
|
||||
} catch {
|
||||
// If decoding fails, use original value for comparison
|
||||
if (
|
||||
style.toLowerCase().includes('expression(') ||
|
||||
style.toLowerCase().includes('behavior:') ||
|
||||
style.toLowerCase().includes('-moz-binding') ||
|
||||
style.toLowerCase().includes('import') ||
|
||||
style.toLowerCase().includes('javascript:') ||
|
||||
style.toLowerCase().includes('vbscript:')
|
||||
) {
|
||||
node.removeAttribute('style')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any attribute that starts with "on" (event handlers)
|
||||
Array.from(node.attributes).forEach((attr) => {
|
||||
if (attr.name.toLowerCase().startsWith('on')) {
|
||||
node.removeAttribute(attr.name)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Comprehensive HTML sanitization to prevent all XSS attacks including:
|
||||
* - Encoded payloads (HTML entities, URL encoding, Unicode)
|
||||
* - SVG-based XSS vectors
|
||||
* - JavaScript URIs and protocol handlers
|
||||
* - Data URIs (except images)
|
||||
* - CSS expressions and injections
|
||||
* - Event handlers
|
||||
* - All dangerous HTML tags
|
||||
*/
|
||||
export function sanitizeHtml(html: string): string {
|
||||
// Configure DOMPurify with strict security settings
|
||||
const config = {
|
||||
// Block all dangerous tags including SVG, form elements and images.
|
||||
// NOTE: 'a' must NOT be in this list — FORBID_TAGS beats ALLOWED_TAGS, and
|
||||
// anchors are intentionally kept (markdown links) then hardened by the
|
||||
// afterSanitizeAttributes hook (http(s)/mailto only, forced target+rel).
|
||||
FORBID_TAGS: [
|
||||
'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'script',
|
||||
'base', 'link', 'meta', 'style', 'svg', 'math', 'form', 'input',
|
||||
'button', 'textarea', 'select', 'option', 'xml', 'xss', 'import',
|
||||
'video', 'audio', 'track', 'source', 'canvas', 'details', 'template',
|
||||
'slot', 'noscript', 'marquee', 'bgsound', 'keygen', 'command',
|
||||
'img', 'area', 'map' // SECURITY: Remove image/map tags completely
|
||||
],
|
||||
|
||||
// Block dangerous attributes
|
||||
FORBID_ATTR: [
|
||||
// Event handlers
|
||||
'onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', 'onmousemove',
|
||||
'onkeydown', 'onkeyup', 'onkeypress', 'onfocus', 'onblur', 'onchange',
|
||||
'onsubmit', 'ondblclick', 'oncontextmenu', 'oninput', 'oninvalid',
|
||||
'onreset', 'onsearch', 'onselect', 'onabort', 'oncanplay', 'oncanplaythrough',
|
||||
'oncuechange', 'ondurationchange', 'onemptied', 'onended', 'onloadeddata',
|
||||
'onloadedmetadata', 'onloadstart', 'onpause', 'onplay', 'onplaying',
|
||||
'onprogress', 'onratechange', 'onseeked', 'onseeking', 'onstalled',
|
||||
'onsuspend', 'ontimeupdate', 'onvolumechange', 'onwaiting', 'ontoggle',
|
||||
'onauxclick', 'ongotpointercapture', 'onlostpointercapture', 'onpointercancel',
|
||||
'onpointerdown', 'onpointerenter', 'onpointerleave', 'onpointermove',
|
||||
'onpointerout', 'onpointerover', 'onpointerup', 'onwheel', 'onanimationcancel',
|
||||
'onanimationend', 'onanimationiteration', 'onanimationstart', 'ontransitioncancel',
|
||||
'ontransitionend', 'ontransitionrun', 'ontransitionstart', 'ondrag', 'ondragend',
|
||||
'ondragenter', 'ondragleave', 'ondragover', 'ondragstart', 'ondrop', 'oncopy',
|
||||
'oncut', 'onpaste', 'onscroll', 'onmessage', 'onmouseenter', 'onmouseleave',
|
||||
'onmousewheel', 'onbeforeunload', 'onerrorupdate', 'onhelp', 'onmove',
|
||||
'onreadystatechange', 'onresize', 'onstart', 'onstop', 'onunload',
|
||||
'onactivate', 'onafterprint', 'onafterupdate', 'onbeforeactivate',
|
||||
'onbeforecopy', 'onbeforecut', 'onbeforedeactivate', 'onbeforeeditfocus',
|
||||
'onbeforepaste', 'onbeforeprint', 'onbeforeupdate', 'onbounce',
|
||||
'oncellchange', 'oncontrolselect', 'ondataavailable', 'ondatasetchanged',
|
||||
'ondatasetcomplete', 'ondeactivate', 'onfilterchange', 'onfinish',
|
||||
'onfocusin', 'onfocusout', 'onlayoutcomplete', 'onlosecapture',
|
||||
'onmoveend', 'onmovestart', 'onpropertychange', 'onresizeend',
|
||||
'onresizestart', 'onrowenter', 'onrowexit', 'onrowsdelete', 'onrowsinserted',
|
||||
'onselectionchange', 'onselectstart', 'onshow', 'onsort', 'onpointerrawupdate',
|
||||
|
||||
// Dangerous attributes
|
||||
'formaction', 'action', 'form', 'srcdoc', 'srcset', 'dynsrc', 'lowsrc',
|
||||
'ping', 'poster', 'background', 'code', 'codebase', 'archive', 'profile',
|
||||
'xmlns', 'xlink:href', 'attributename', 'from', 'to', 'values', 'begin',
|
||||
'autofocus', 'autoplay', 'controls', 'manifest', 'sandbox',
|
||||
// SECURITY: block resource-loading attributes. 'href' is intentionally NOT
|
||||
// here (FORBID_ATTR beats ALLOWED_ATTR): markdown links need it, and the
|
||||
// afterSanitizeAttributes hook strips any href that isn't http(s)/mailto.
|
||||
'src', 'data'
|
||||
],
|
||||
|
||||
// Only allow safe protocols
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||||
|
||||
// SECURITY: Strip ALL HTML tags to prevent rendering exploits
|
||||
// Only allow basic text formatting for markdown (no links, images, or any potentially dangerous tags)
|
||||
ALLOWED_TAGS: [
|
||||
'a',
|
||||
'b', 'i', 'u', 'strong', 'em', 'p', 'br', 'ul', 'ol', 'li',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'span', 'div',
|
||||
'del', 'hr', 'sup', 'sub', 'abbr', 'cite', 'dfn', 'kbd', 'mark',
|
||||
'q', 'samp', 'small', 'time', 'var'
|
||||
],
|
||||
|
||||
// Allow safe link attributes only (href is protocol-restricted + target/rel are
|
||||
// forced by the afterSanitizeAttributes hook above). No src or resource-loading attrs.
|
||||
ALLOWED_ATTR: [
|
||||
'href', 'target', 'rel',
|
||||
'title', 'class', 'id', 'align', 'colspan', 'rowspan'
|
||||
],
|
||||
|
||||
// Return a string instead of a document
|
||||
RETURN_DOM: false,
|
||||
RETURN_DOM_FRAGMENT: false,
|
||||
|
||||
// Keep HTML comments removed
|
||||
ALLOW_DATA_ATTR: false
|
||||
// NOTE: do NOT set USE_PROFILES here — it overrides ALLOWED_TAGS/ALLOWED_ATTR,
|
||||
// which would drop the <a> tags we explicitly allow above. The explicit
|
||||
// ALLOWED_TAGS/ALLOWED_ATTR allowlist is authoritative; protocols are still
|
||||
// restricted by the afterSanitizeAttributes hook (href → http/https/mailto only).
|
||||
}
|
||||
|
||||
// Sanitize the HTML using the pre-configured hooks
|
||||
return DOMPurify.sanitize(html, config)
|
||||
}
|
||||
Reference in new issue
Block a user