feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { Marked } from 'marked'
|
||||
import xssPkg from 'xss'
|
||||
const { escapeAttrValue, filterXSS, friendlyAttrValue, safeAttrValue, whiteList } = xssPkg
|
||||
|
||||
const ALLOWED_LINK_SCHEMES = new Set(['http', 'https', 'mailto', 'tel'])
|
||||
const ALLOWED_IMAGE_SCHEMES = new Set(['http', 'https'])
|
||||
const SAFE_RASTER_DATA_URL = /^data:image\/(?:avif|gif|jpe?g|png|webp);base64,[a-z0-9+/=\s]+$/i
|
||||
|
||||
const escapeHtml = (text) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
const getUrlScheme = (value) => {
|
||||
const colonIndex = value.indexOf(':')
|
||||
if (colonIndex < 0) return null
|
||||
|
||||
const pathBoundaryIndexes = ['/', '?', '#']
|
||||
.map((character) => value.indexOf(character))
|
||||
.filter((index) => index >= 0)
|
||||
const firstPathBoundary = pathBoundaryIndexes.length
|
||||
? Math.min(...pathBoundaryIndexes)
|
||||
: Number.POSITIVE_INFINITY
|
||||
|
||||
if (firstPathBoundary < colonIndex) return null
|
||||
|
||||
const scheme = value.slice(0, colonIndex).replace(/\s/g, '')
|
||||
return /^[a-z][a-z0-9+.-]*$/i.test(scheme) ? scheme.toLowerCase() : 'invalid'
|
||||
}
|
||||
|
||||
const isSafeUrl = (tag, name, value) => {
|
||||
const scheme = getUrlScheme(value)
|
||||
if (!scheme) return true
|
||||
|
||||
if (name === 'href') return ALLOWED_LINK_SCHEMES.has(scheme)
|
||||
if (name === 'src') {
|
||||
if (tag === 'img' && scheme === 'data') return SAFE_RASTER_DATA_URL.test(value)
|
||||
return ALLOWED_IMAGE_SCHEMES.has(scheme)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const sanitizeAttributeValue = (tag, name, value, cssFilter) => {
|
||||
if (name !== 'href' && name !== 'src') {
|
||||
return safeAttrValue(tag, name, value, cssFilter)
|
||||
}
|
||||
|
||||
const normalizedValue = friendlyAttrValue(value).trim()
|
||||
return isSafeUrl(tag, name, normalizedValue) ? escapeAttrValue(normalizedValue) : ''
|
||||
}
|
||||
|
||||
// 创建独立的 marked 实例,避免全局污染和 HMR 重复注册
|
||||
const markedInstance = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer: {
|
||||
html(token) {
|
||||
const rawHtml = typeof token === 'string' ? token : token.text
|
||||
return escapeHtml(rawHtml || '')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// xss 白名单:默认白名单 + GFM 任务列表 checkbox + code 类名(为语法高亮预留)
|
||||
const xssOptions = {
|
||||
whiteList: {
|
||||
...whiteList,
|
||||
input: ['type', 'checked', 'disabled'],
|
||||
code: ['class']
|
||||
},
|
||||
safeAttrValue: sanitizeAttributeValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Markdown 文本渲染为安全 HTML
|
||||
* 两层防护:marked 禁用原始 HTML + xss 清洗(禁 script、on* 事件、javascript: 协议)
|
||||
*
|
||||
* @param {string} text Markdown 文本
|
||||
* @returns {string} 安全的 HTML 字符串
|
||||
*/
|
||||
export function renderMarkdown(text) {
|
||||
if (!text || typeof text !== 'string') return ''
|
||||
const rawHtml = markedInstance.parse(text)
|
||||
return filterXSS(rawHtml, xssOptions)
|
||||
}
|
||||
Reference in New Issue
Block a user