84 lines
2.6 KiB
JavaScript
84 lines
2.6 KiB
JavaScript
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)
|
|
}
|