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 全绿;二进制端到端冒烟通过
99 lines
3.3 KiB
TypeScript
99 lines
3.3 KiB
TypeScript
/** 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)、站内相对路径、锚点与 mailto(src 另允许 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 }
|
||
}
|