Files
FileShare/web/src/utils/markdown.ts
T
SKYMirror 7f060dd0e4 26.9(安全审计修复版)
Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务:

- 安全审计全部修复(docs/security-audit-2026-09-05.md):
  bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、
  全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、
  管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、
  会话默认 7 天、限流缓存故障降级、robots.txt 端点等
- 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固
- Redis 支持库号(FCB_REDIS_DB / redis://…/db URL)
- 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、
  提码 5 位起、chunk 32MiB 上限、admin 审计动作等)

验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
2026-09-05 04:22:41 +08:00

99 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Markdown 渲染:marked 解析 + DOM 级净化(防 XSS)+ 标题锚点 + 目录提取 */
import { marked } from 'marked'
export interface TocItem {
id: string
text: string
level: number
}
export interface RenderResult {
html: string
toc: TocItem[]
}
function slugify(text: string, used: Set<string>): string {
const base =
text
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.trim()
.replace(/\s+/g, '-') || 'section'
let slug = base
let i = 2
while (used.has(slug)) slug = `${base}-${i++}`
used.add(slug)
return slug
}
// L7 加固:补齐 foreign-content/嵌入类标签与危险属性清单(原仅黑名单 9 类 +
// on* + javascript:,未覆盖 srcdoc/formaction/xlink:href/srcset/data: URL 等)
const BLOCKED_TAGS = new Set([
'script', 'style', 'iframe', 'object', 'embed', 'form', 'link', 'meta', 'base',
'svg', 'math', 'frame', 'frameset', 'applet', 'template', 'noscript', 'title',
])
/** 危险属性:事件类、URL 注入类与沙箱逃逸类 */
const BLOCKED_ATTRS = new Set([
'srcdoc', 'sandbox', 'formaction', 'action', 'xlink:href', 'srcset', 'poster', 'background', 'dynsrc', 'lowsrc', 'data',
])
/** 仅允许 http(s)、站内相对路径、锚点与 mailtosrc 另允许 data:image/ */
function safeUrl(value: string, isSrc: boolean): boolean {
const v = value.trim()
if (isSrc && /^data:image\//i.test(v)) return true
return /^(https?:|mailto:|\/|#|\.\/)/i.test(v) || !/^[a-z][a-z0-9+.-]*:/i.test(v)
}
/** 净化 HTML:移除危险标签/危险属性/非白名单协议 URL */
export function sanitizeHtml(html: string): string {
const doc = new DOMParser().parseFromString(html, 'text/html')
for (const el of [...doc.body.querySelectorAll('*')]) {
const tag = el.tagName.toLowerCase()
if (BLOCKED_TAGS.has(tag)) {
el.remove()
continue
}
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase()
if (name.startsWith('on') || BLOCKED_ATTRS.has(name)) {
el.removeAttribute(attr.name)
continue
}
if ((name === 'href' || name === 'src' || name.endsWith(':src') || name.endsWith(':href')) &&
!safeUrl(attr.value, name === 'src')) {
el.removeAttribute(attr.name)
}
}
}
return doc.body.innerHTML
}
/** 渲染 markdown → 净化后的 HTML + 标题目录(h2/h3 作为二级/三级目录) */
export function renderMarkdown(md: string): RenderResult {
const rawHtml = marked.parse(md, { gfm: true, breaks: false }) as string
const doc = new DOMParser().parseFromString(rawHtml, 'text/html')
// 外链新窗口打开;站内锚点保持
for (const a of [...doc.body.querySelectorAll('a[href]')]) {
const href = a.getAttribute('href') ?? ''
if (/^https?:\/\//i.test(href)) {
a.setAttribute('target', '_blank')
a.setAttribute('rel', 'noopener noreferrer')
}
}
const used = new Set<string>()
const toc: TocItem[] = []
for (const h of [...doc.body.querySelectorAll('h1, h2, h3')]) {
const level = Number(h.tagName.substring(1))
const text = (h.textContent ?? '').trim()
if (!text) continue
const id = slugify(text, used)
h.setAttribute('id', id)
if (level >= 2) toc.push({ id, text, level })
}
return { html: sanitizeHtml(doc.body.innerHTML), toc }
}