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:
2026-09-05 04:22:41 +08:00
commit 7f060dd0e4
173 changed files with 32455 additions and 0 deletions
+98
View File
@@ -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)、站内相对路径、锚点与 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 }
}