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 全绿;二进制端到端冒烟通过
259 lines
7.3 KiB
Go
259 lines
7.3 KiB
Go
// Package settings — sanitize.go:受控 HTML 白名单净化(安全审计 L7)。
|
||
//
|
||
// notify_content 设计上「允许 <a> 等受控 HTML」,此前由管理端任意写入并经
|
||
// 前端 v-html 直出——管理员账号一旦被盗即可对全站访客注入脚本。
|
||
// 本净化器只保留纯文本与 <a href="http(s)|/|#">,其余标签连同其内层内容
|
||
// 一并丢弃(不做 HTML 转义输出,避免脚本字面量进入页面 DOM),
|
||
// 在公开配置读取与保存两处调用(双保险,覆盖历史存量数据)。
|
||
package settings
|
||
|
||
import (
|
||
"strings"
|
||
)
|
||
|
||
// dropContentTags 标签内部内容也一并丢弃的危险标签(script/style 等)。
|
||
var dropContentTags = map[string]bool{
|
||
"script": true, "style": true, "iframe": true, "object": true, "embed": true,
|
||
"title": true, "textarea": true, "noscript": true, "template": true,
|
||
"svg": true, "math": true, "xmp": true, "noembed": true, "noframes": true,
|
||
}
|
||
|
||
// SanitizeInlineHTML 白名单净化内联 HTML:
|
||
// - <script>/<style>/<iframe> 等危险标签连同内部内容整体丢弃;
|
||
// - 其他非 <a> 标签仅丢弃标签本身、保留其内层文本(如 <b>加粗</b> → 加粗);
|
||
// - <a> 仅保留 href 属性,且值必须以 http://、https://、/ 或 # 开头;
|
||
// - HTML 注释(<!-- -->)丢弃,未闭合的危险标签丢弃其后全部内容;
|
||
// - 文本片段原样保留(不含 '<',渲染时为安全文本节点)。
|
||
func SanitizeInlineHTML(input string) string {
|
||
if input == "" {
|
||
return ""
|
||
}
|
||
var b strings.Builder
|
||
b.Grow(len(input))
|
||
i := 0
|
||
pendingAnchor := false
|
||
writeClose := func() {
|
||
if pendingAnchor {
|
||
b.WriteString("</a>")
|
||
pendingAnchor = false
|
||
}
|
||
}
|
||
for i < len(input) {
|
||
lt := strings.IndexByte(input[i:], '<')
|
||
if lt < 0 {
|
||
b.WriteString(input[i:])
|
||
break
|
||
}
|
||
b.WriteString(input[i : i+lt])
|
||
rest := input[i+lt:]
|
||
// 注释:整体丢弃
|
||
if strings.HasPrefix(rest, "<!--") {
|
||
end := strings.Index(rest, "-->")
|
||
if end < 0 {
|
||
break // 未闭合注释:丢弃剩余全部
|
||
}
|
||
i += lt + end + 3
|
||
continue
|
||
}
|
||
end := findTagEnd(rest)
|
||
if end < 0 {
|
||
break // 未闭合标签:丢弃剩余全部(不当作文本,防 < 绕过)
|
||
}
|
||
rawTag := rest[:end+1] // 形如 "<a href=..>"、"</div>"、"<img .../>"
|
||
name, closing, _ := parseTagName(rawTag)
|
||
if name != "" && !closing {
|
||
if dropContentTags[name] {
|
||
// 危险标签:连内层跳到对应闭合标签;无闭合(如 <script> 到结尾)则全丢
|
||
closeIdx := findClosingTag(input, i+lt+end+1, name)
|
||
if closeIdx < 0 {
|
||
writeClose()
|
||
return b.String()
|
||
}
|
||
i = closeIdx
|
||
continue
|
||
}
|
||
if name == "a" {
|
||
writeClose()
|
||
if href, ok := parseAllowedAnchor(rawTag); ok {
|
||
b.WriteString(`<a href="` + escapeAttr(href) + `">`)
|
||
pendingAnchor = true
|
||
}
|
||
// href 非法的 <a>:标签丢弃,但内层文本仍保留
|
||
}
|
||
// 其余开标签:丢弃标签本身,保留内层文本
|
||
i += lt + end + 1
|
||
continue
|
||
}
|
||
if name != "" && closing && name == "a" {
|
||
writeClose() // 仅在存在未闭合的合法 <a> 时输出
|
||
}
|
||
// 其余闭标签:丢弃
|
||
i += lt + end + 1
|
||
}
|
||
writeClose()
|
||
return b.String()
|
||
}
|
||
|
||
// findTagEnd 返回标签结束 '>' 的下标(跳过引号内的 '>',如 href="a<b">);未找到返回 -1。
|
||
func findTagEnd(s string) int {
|
||
inQuote := byte(0)
|
||
for i := 0; i < len(s); i++ {
|
||
c := s[i]
|
||
if inQuote != 0 {
|
||
if c == inQuote {
|
||
inQuote = 0
|
||
}
|
||
continue
|
||
}
|
||
switch c {
|
||
case '"', '\'':
|
||
inQuote = c
|
||
case '>':
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// parseTagName 解析标签名:返回 (小写名, 是否闭合标签, 是否自闭合 "/>")。
|
||
func parseTagName(tag string) (name string, closing, selfClosing bool) {
|
||
if len(tag) < 3 || tag[0] != '<' || tag[len(tag)-1] != '>' {
|
||
return "", false, false
|
||
}
|
||
inner := tag[1 : len(tag)-1]
|
||
if strings.HasSuffix(inner, "/") {
|
||
selfClosing = true
|
||
inner = inner[:len(inner)-1]
|
||
}
|
||
if strings.HasPrefix(inner, "/") {
|
||
closing = true
|
||
inner = inner[1:]
|
||
}
|
||
end := 0
|
||
for end < len(inner) {
|
||
r := inner[end]
|
||
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
||
break
|
||
}
|
||
end++
|
||
}
|
||
if end == 0 {
|
||
return "", closing, selfClosing
|
||
}
|
||
return strings.ToLower(inner[:end]), closing, selfClosing
|
||
}
|
||
|
||
// findClosingTag 从 from 开始查找 </name>,返回闭合标签结束位置(不含);找不到返回 -1。
|
||
func findClosingTag(s string, from int, name string) int {
|
||
needle := "</" + name
|
||
lower := strings.ToLower(s)
|
||
pos := from
|
||
for {
|
||
idx := strings.Index(lower[pos:], needle)
|
||
if idx < 0 {
|
||
return -1
|
||
}
|
||
at := pos + idx
|
||
after := at + len(needle)
|
||
if after < len(s) {
|
||
r := lower[after]
|
||
if r != '>' && r != ' ' && r != '\t' && r != '\n' && r != '\r' && r != '/' {
|
||
pos = after
|
||
continue // 形如 </scriptx> 的伪闭合,继续找
|
||
}
|
||
}
|
||
end := strings.IndexByte(s[after:], '>')
|
||
if end < 0 {
|
||
return -1
|
||
}
|
||
return after + end + 1
|
||
}
|
||
}
|
||
|
||
// parseAllowedAnchor 解析 <a ...> 标签:仅当 href 合法时返回 (href, true)。
|
||
func parseAllowedAnchor(tag string) (string, bool) {
|
||
inner := tag[1 : len(tag)-1]
|
||
// 标签名
|
||
nameEnd := 0
|
||
for nameEnd < len(inner) {
|
||
r := inner[nameEnd]
|
||
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
||
break
|
||
}
|
||
nameEnd++
|
||
}
|
||
href, found := scanAttr(inner[nameEnd:], "href")
|
||
if !found {
|
||
return "", false // 无 href 的 <a> 不放行(避免依赖默认行为)
|
||
}
|
||
href = strings.TrimSpace(href)
|
||
lower := strings.ToLower(href)
|
||
if !(strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") ||
|
||
strings.HasPrefix(href, "/") || strings.HasPrefix(href, "#")) {
|
||
return "", false // javascript:/data: 等一律拒绝
|
||
}
|
||
return href, true
|
||
}
|
||
|
||
// scanAttr 扫描属性串中的目标属性(支持双引号/单引号/无引号值)。
|
||
func scanAttr(s, name string) (string, bool) {
|
||
lower := strings.ToLower(s)
|
||
want := strings.ToLower(name)
|
||
for i := 0; i < len(lower); {
|
||
// 跳过空白
|
||
for i < len(lower) && (lower[i] == ' ' || lower[i] == '\t' || lower[i] == '\n' || lower[i] == '\r') {
|
||
i++
|
||
}
|
||
if i >= len(lower) {
|
||
break
|
||
}
|
||
// 属性名
|
||
start := i
|
||
for i < len(lower) && lower[i] != '=' && lower[i] != ' ' && lower[i] != '\t' && lower[i] != '\n' && lower[i] != '\r' {
|
||
i++
|
||
}
|
||
attrName := lower[start:i]
|
||
// 跳过空白
|
||
for i < len(lower) && (lower[i] == ' ' || lower[i] == '\t' || lower[i] == '\n' || lower[i] == '\r') {
|
||
i++
|
||
}
|
||
if i < len(lower) && lower[i] == '=' {
|
||
i++ // 跳过 '='
|
||
for i < len(lower) && (lower[i] == ' ' || lower[i] == '\t' || lower[i] == '\n' || lower[i] == '\r') {
|
||
i++
|
||
}
|
||
var val string
|
||
if i < len(lower) && (lower[i] == '"' || lower[i] == '\'') {
|
||
q := lower[i]
|
||
i++
|
||
vs := i
|
||
for i < len(lower) && lower[i] != q {
|
||
i++
|
||
}
|
||
val = s[vs:i]
|
||
if i < len(lower) {
|
||
i++ // 跳过闭合引号
|
||
}
|
||
} else {
|
||
vs := i
|
||
for i < len(lower) && lower[i] != ' ' && lower[i] != '\t' && lower[i] != '\n' && lower[i] != '\r' {
|
||
i++
|
||
}
|
||
val = s[vs:i]
|
||
}
|
||
if attrName == want {
|
||
return val, true
|
||
}
|
||
} else if attrName == want {
|
||
return "", true // 布尔属性:存在即命中(值空,调用方按非法处理)
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// escapeAttr HTML 属性转义。
|
||
func escapeAttr(s string) string {
|
||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||
return r.Replace(s)
|
||
}
|