FileCodeBox Go 重写版 v2.5.6(安全审计修复版)

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 9686fe887a
173 changed files with 32455 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
// 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("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&#34;", "'", "&#39;")
return r.Replace(s)
}