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 全绿;二进制端到端冒烟通过
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
/** 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user