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:
@@ -0,0 +1,150 @@
|
||||
// settings 包双方言测试:Manager 全流程(ensure 行、KV 读写合并、Reload、
|
||||
// UpdateKV 屏蔽内部键、SystemStart)分别在 sqlite(默认)与 postgres(FCB_TEST_PG_DSN)上执行。
|
||||
package settings_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/config"
|
||||
"filecodebox/internal/database"
|
||||
"filecodebox/internal/settings"
|
||||
)
|
||||
|
||||
// pgTestDSN 返回 Postgres 测试连接串;未设置 FCB_TEST_PG_DSN 时跳过。
|
||||
func pgTestDSN(t *testing.T) string {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("FCB_TEST_PG_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 FCB_TEST_PG_DSN,跳过 Postgres 双方言用例")
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
// newTestManager 按方言构造库 + Manager(已完成 Migrate)。
|
||||
func newTestManager(t *testing.T, driver, dsn string) (*settings.Manager, *gorm.DB, func()) {
|
||||
t.Helper()
|
||||
if dsn == "" {
|
||||
dsn = filepath.Join(t.TempDir(), "settings-test.db")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, database.Options{Driver: driver, DSN: dsn})
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] Open: %v", driver, err)
|
||||
}
|
||||
if err := database.Migrate(ctx, db); err != nil {
|
||||
_ = database.Close(db)
|
||||
t.Fatalf("[%s] Migrate: %v", driver, err)
|
||||
}
|
||||
// Config 仅作内存载体(驱动不回连数据库),postgres 模式给占位 DSN 以通过校验
|
||||
t.Setenv("FCB_DB_DRIVER", driver)
|
||||
if driver == "postgres" {
|
||||
t.Setenv("FCB_DB_DSN", dsn)
|
||||
} else {
|
||||
t.Setenv("FCB_DB_DSN", "")
|
||||
}
|
||||
cfg, err := config.New()
|
||||
if err != nil {
|
||||
_ = database.Close(db)
|
||||
t.Fatalf("[%s] config.New: %v", driver, err)
|
||||
}
|
||||
mgr, err := settings.NewManager(ctx, db, cfg)
|
||||
if err != nil {
|
||||
_ = database.Close(db)
|
||||
t.Fatalf("[%s] NewManager: %v", driver, err)
|
||||
}
|
||||
return mgr, db, func() { _ = database.Close(db) }
|
||||
}
|
||||
|
||||
// runManagerSuite 双方言共用的 Manager 行为断言。
|
||||
func runManagerSuite(t *testing.T, mgr *settings.Manager) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. 初始:未初始化(admin_token 空)
|
||||
if mgr.IsInitialized() {
|
||||
t.Fatal("初始 admin_token 为空应视为未初始化")
|
||||
}
|
||||
|
||||
// 2. UpdateKV 写入策略键 → Reload 后读取生效
|
||||
patch := map[string]any{
|
||||
settings.KeyBackgroundURL: "https://example.com/bg.png",
|
||||
settings.KeyFooterText: "自建部署,仅供内部演示",
|
||||
settings.KeyFooterBeian: "京ICP备2024000001号-1",
|
||||
settings.KeyNotifyEnabled: 0,
|
||||
settings.KeyMaxSaveSeconds: 86400,
|
||||
"_internal_secret": "must-drop", // 下划线内部键必须被拒
|
||||
}
|
||||
if err := mgr.UpdateKV(ctx, patch); err != nil {
|
||||
t.Fatalf("UpdateKV 失败: %v", err)
|
||||
}
|
||||
if err := mgr.Reload(ctx); err != nil {
|
||||
t.Fatalf("Reload 失败: %v", err)
|
||||
}
|
||||
cfg := mgr.Get()
|
||||
if got := cfg.GetString(settings.KeyBackgroundURL); got != "https://example.com/bg.png" {
|
||||
t.Fatalf("background_url 未生效: %q", got)
|
||||
}
|
||||
if got := cfg.GetString(settings.KeyFooterBeian); got != "京ICP备2024000001号-1" {
|
||||
t.Fatalf("footer_beian 未生效: %q", got)
|
||||
}
|
||||
if cfg.GetBool(settings.KeyNotifyEnabled) {
|
||||
t.Fatal("notify_enabled=0 应生效")
|
||||
}
|
||||
if got := cfg.MaxSaveSeconds(); got != 86400 {
|
||||
t.Fatalf("max_save_seconds 未生效: %d", got)
|
||||
}
|
||||
if _, ok := cfg.Get("_internal_secret"); ok {
|
||||
t.Fatal("下划线内部键不应进入运行时配置")
|
||||
}
|
||||
|
||||
// 3. KV 合并语义:二次 UpdateKV 不覆盖未提及键
|
||||
if err := mgr.UpdateKV(ctx, map[string]any{settings.KeyNotifyEnabled: 1}); err != nil {
|
||||
t.Fatalf("二次 UpdateKV: %v", err)
|
||||
}
|
||||
if err := mgr.Reload(ctx); err != nil {
|
||||
t.Fatalf("二次 Reload: %v", err)
|
||||
}
|
||||
cfg = mgr.Get()
|
||||
if !cfg.GetBool(settings.KeyNotifyEnabled) {
|
||||
t.Fatal("notify_enabled 二次写入应生效")
|
||||
}
|
||||
if got := cfg.GetString(settings.KeyFooterText); got == "" {
|
||||
t.Fatal("二次写入不应清空 footer_text")
|
||||
}
|
||||
|
||||
// 4. SystemStart:sys_start 键写入且为毫秒时间戳
|
||||
mgr.SystemStart(ctx)
|
||||
|
||||
// 5. 敏感键判定(双模式一致)
|
||||
if !settings.IsSensitiveKey("admin_token") || !settings.IsSensitiveKey("jwt_secret") {
|
||||
t.Fatal("admin_token/jwt_secret 应为敏感键")
|
||||
}
|
||||
if settings.IsSensitiveKey("footer_text") {
|
||||
t.Fatal("footer_text 不应为敏感键")
|
||||
}
|
||||
|
||||
// 6. KV schema 表完整性:全部键可从默认值读取
|
||||
for _, e := range settings.KVSchema() {
|
||||
if _, ok := cfg.Get(e.Key); !ok {
|
||||
t.Fatalf("schema 键 %q 在默认配置中不存在", e.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerSQLite(t *testing.T) {
|
||||
mgr, _, closeFn := newTestManager(t, "sqlite", "")
|
||||
defer closeFn()
|
||||
runManagerSuite(t, mgr)
|
||||
}
|
||||
|
||||
func TestManagerPostgres(t *testing.T) {
|
||||
dsn := pgTestDSN(t)
|
||||
mgr, _, closeFn := newTestManager(t, "postgres", dsn)
|
||||
defer closeFn()
|
||||
runManagerSuite(t, mgr)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Package settings 密码哈希与校验:
|
||||
// 新密码使用 bcrypt(格式 bcrypt$<bcrypt原生哈希串>);同时兼容两代旧格式——
|
||||
// sha256$salt$hash(上一版)与旧版明文(迁移校验)。
|
||||
// 安全审计 M1:单轮 SHA256+盐抗 GPU 爆破不足,新哈希统一升级 bcrypt。
|
||||
// 兼容策略:VerifyPassword 支持全部三代格式;调用方可用 NeedsRehash 判定
|
||||
// 登录成功后是否需要用新算法重哈希写回(登录升级路径见 api.adminLogin)。
|
||||
package settings
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// bcryptCost bcrypt 工作因子:12(2026 年桌面 CPU 单次校验约 100-250ms,
|
||||
// 离线爆破成本相比单轮 SHA256 提升数个数量级)。
|
||||
const bcryptCost = 12
|
||||
|
||||
// bcryptMaxLen bcrypt 算法只取前 72 字节;超长输入统一截断,
|
||||
// 避免 GenerateFromPassword/CompareHashAndPassword 对 >72 字节返回错误。
|
||||
const bcryptMaxLen = 72
|
||||
|
||||
func bcryptBytes(password string) []byte {
|
||||
b := []byte(password)
|
||||
if len(b) > bcryptMaxLen {
|
||||
b = b[:bcryptMaxLen]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// HashPassword 生成 bcrypt$<hash> 格式密码哈希(<hash> 为 bcrypt 原生串
|
||||
// `$2a$<cost>$<salt><hash>`,cost 内嵌于哈希串中)。
|
||||
func HashPassword(password string) string {
|
||||
sum, err := bcrypt.GenerateFromPassword(bcryptBytes(password), bcryptCost)
|
||||
if err != nil {
|
||||
// 截断后仅剩非法 cost 等实现级错误:确定性失败优于弱哈希回落
|
||||
panic("settings: bcrypt 哈希失败: " + err.Error())
|
||||
}
|
||||
return "bcrypt$" + string(sum)
|
||||
}
|
||||
|
||||
// VerifyPassword 校验密码:支持 bcrypt$、sha256$salt$hash 与旧版明文三种格式。
|
||||
func VerifyPassword(password, hashed string) bool {
|
||||
if hashed == "" {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(hashed, "bcrypt$"):
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashed[len("bcrypt$"):]), bcryptBytes(password)) == nil
|
||||
case strings.HasPrefix(hashed, "sha256$"):
|
||||
parts := strings.Split(hashed, "$")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
salt, stored := parts[1], parts[2]
|
||||
sum := sha256.Sum256([]byte(salt + password))
|
||||
return hmac.Equal([]byte(hex.EncodeToString(sum[:])), []byte(stored))
|
||||
}
|
||||
// 旧版明文比较(兼容迁移)
|
||||
return hmac.Equal([]byte(password), []byte(hashed))
|
||||
}
|
||||
|
||||
// NeedsRehash 判断哈希是否需要升级为当前算法/成本(登录成功后判定,透明迁移)。
|
||||
// sha256 与明文一律 true;bcrypt 成本低于当前 bcryptCost 时 true。
|
||||
func NeedsRehash(hashed string) bool {
|
||||
if !strings.HasPrefix(hashed, "bcrypt$") {
|
||||
return true
|
||||
}
|
||||
// bcrypt 原生串格式:$2a$<cost>$<salt><hash>
|
||||
parts := strings.Split(hashed[len("bcrypt$"):], "$")
|
||||
if len(parts) < 4 {
|
||||
return true
|
||||
}
|
||||
cost, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return cost < bcryptCost
|
||||
}
|
||||
|
||||
// IsPasswordHashed 判断是否为受支持的哈希格式(bcrypt / sha256)。
|
||||
func IsPasswordHashed(s string) bool {
|
||||
return strings.HasPrefix(s, "bcrypt$") || strings.HasPrefix(s, "sha256$")
|
||||
}
|
||||
|
||||
// GenerateJWTSecret 生成 64 字符十六进制随机密钥。
|
||||
func GenerateJWTSecret() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("settings: crypto/rand 不可用: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package settings
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHashPasswordRoundTrip(t *testing.T) {
|
||||
h := HashPassword("s3cret-密码")
|
||||
if !IsPasswordHashed(h) {
|
||||
t.Fatalf("哈希格式不对: %s", h)
|
||||
}
|
||||
if !VerifyPassword("s3cret-密码", h) {
|
||||
t.Fatal("正确密码校验失败")
|
||||
}
|
||||
if VerifyPassword("wrong", h) {
|
||||
t.Fatal("错误密码竟通过校验")
|
||||
}
|
||||
if h == HashPassword("s3cret-密码") {
|
||||
t.Fatal("盐值未随机化")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLegacyPlaintext(t *testing.T) {
|
||||
if !VerifyPassword("FileCodeBox2023", "FileCodeBox2023") {
|
||||
t.Fatal("旧版明文兼容校验失败")
|
||||
}
|
||||
if VerifyPassword("nope", "FileCodeBox2023") {
|
||||
t.Fatal("明文比较不应放行其他密码")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJWTSecretLength(t *testing.T) {
|
||||
s := GenerateJWTSecret()
|
||||
if len(s) < 32 {
|
||||
t.Fatalf("密钥太短: %d", len(s))
|
||||
}
|
||||
if s == GenerateJWTSecret() {
|
||||
t.Fatal("密钥未随机化")
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// sanitize_test.go — SanitizeInlineHTML 单测(安全审计 L7)。
|
||||
package settings
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeInlineHTML(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"空串", "", ""},
|
||||
{"纯文本保留", "欢迎使用文件快传", "欢迎使用文件快传"},
|
||||
{"合法链接保留", `<a href="https://example.com">官网</a>`, `<a href="https://example.com">官网</a>`},
|
||||
{"相对路径链接", `<a href="/docs">文档</a>`, `<a href="/docs">文档</a>`},
|
||||
{"锚点链接", `<a href="#top">顶部</a>`, `<a href="#top">顶部</a>`},
|
||||
{"script 整体丢弃", `hello<script>alert(1)</script>world`, "helloworld"},
|
||||
{"img 丢弃保留文本", `a<img src=x onerror=alert(1)>b`, "ab"},
|
||||
{"javascript href 拒绝", `<a href="javascript:alert(1)">x</a>`, "x"},
|
||||
{"data href 拒绝", `<a href="data:text/html,<script>">x</a>`, "x"},
|
||||
{"事件属性不透传", `<a href="/x" onclick="evil()">y</a>`, `<a href="/x">y</a>`},
|
||||
{"注释丢弃", `a<!-- secret -->b`, "ab"},
|
||||
{"未闭合标签丢弃剩余", `ok<script>alert(1)`, "ok"},
|
||||
{"iframe 丢弃", `<iframe src="//evil"></iframe>text`, "text"},
|
||||
{"样式标签丢弃", `<style>*{}</style>plain`, "plain"},
|
||||
{"嵌套危险标签", `<div onclick=e><b>bold</b></div>`, "bold"},
|
||||
{"大小写标签", `<A HREF="https://e.com">L</A>`, `<a href="https://e.com">L</a>`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := SanitizeInlineHTML(tc.in); got != tc.want {
|
||||
t.Fatalf("SanitizeInlineHTML(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeInlineHTMLNoScriptContent(t *testing.T) {
|
||||
// script 内部文本也必须丢弃(不做 HTML 转义输出,避免 alert 字样进入页面 DOM)
|
||||
got := SanitizeInlineHTML(`<script>var x = "</b>"; alert(1)</script>fine`)
|
||||
if got != "fine" {
|
||||
t.Fatalf("script 内容应整体丢弃, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package settings — schema.go:v2 配置键 schema 常量与元数据表。
|
||||
//
|
||||
// 键名常量的单一事实来源在 internal/config/schema.go(defaults() 需引用);
|
||||
// 本文件 re-export 供 API/管理层使用,并提供「键名/类型/默认值」全量表,
|
||||
// 供管理端设置页与文档生成(t4)对齐。新增键必须同步:
|
||||
// 1. config/schema.go 键名与边界常量
|
||||
// 2. config/config.go defaults() 默认值
|
||||
// 3. 本文件 KVSchema() 元数据行
|
||||
// 4. schema 同步测试(config schema_test / settings schema_test)
|
||||
package settings
|
||||
|
||||
import "filecodebox/internal/config"
|
||||
|
||||
// —— 键名 re-export(与 config 包保持同一字符串,避免魔法值散落)——
|
||||
const (
|
||||
// 需求 ① 背景图
|
||||
KeyBackground = config.KeyBackground
|
||||
KeyBackgroundURL = config.KeyBackgroundURL
|
||||
// 需求 ② 页脚
|
||||
KeyFooterText = config.KeyFooterText
|
||||
KeyFooterBeian = config.KeyFooterBeian
|
||||
// 需求 ③ 系统通知
|
||||
KeyNotifyEnabled = config.KeyNotifyEnabled
|
||||
KeyNotifyTitle = config.KeyNotifyTitle
|
||||
KeyNotifyContent = config.KeyNotifyContent
|
||||
// 需求 ④ 保存策略与上传频率限制
|
||||
KeyMaxSaveSeconds = config.KeyMaxSaveSeconds
|
||||
KeyMaxSaveCount = config.KeyMaxSaveCount
|
||||
KeyExpireStyle = config.KeyExpireStyle
|
||||
KeyUploadCount = config.KeyUploadCount
|
||||
KeyUploadMinute = config.KeyUploadMinute
|
||||
// 需求 ④⑩ 存储策略
|
||||
KeyUploadSize = config.KeyUploadSize
|
||||
KeyMaxFileSize = config.KeyMaxFileSize
|
||||
KeyAllowedTypes = config.KeyAllowedTypes
|
||||
KeyStorageLimit = config.KeyStorageLimit
|
||||
KeyOpenUpload = config.KeyOpenUpload
|
||||
// v3 存储引擎
|
||||
KeyStorageEngine = config.KeyStorageEngine
|
||||
)
|
||||
|
||||
// —— 取值边界 re-export ——
|
||||
const (
|
||||
MaxSaveSecondsMax = config.MaxSaveSecondsMax // 最长保存秒数上限(365 天)
|
||||
MaxSaveCountMax = config.MaxSaveCountMax // 保存次数上限
|
||||
MaxFileSizeMax = config.MaxFileSizeMax // 单文件大小上限(10 GiB)
|
||||
BackgroundURLMaxLen = config.BackgroundURLMaxLen // 背景图 URL 长度上限
|
||||
FooterTextMaxLen = config.FooterTextMaxLen // 页脚内容长度上限
|
||||
FooterBeianMaxLen = config.FooterBeianMaxLen // 备案号长度上限
|
||||
NotifyTitleMaxLen = config.NotifyTitleMaxLen // 通知标题长度上限
|
||||
NotifyContentMaxLen = config.NotifyContentMaxLen // 通知内容长度上限
|
||||
)
|
||||
|
||||
// 敏感键:不允许出现在管理端 config get 下发/前端可见集合中(双模式下一致生效)。
|
||||
// v3:引擎凭据(webdav_password/s3_secret_access_key/aws_session_token)加入敏感集——
|
||||
// 管理端 get 返回掩码占位,update 时空串/掩码=不修改;公开 config 永不下发。
|
||||
var SensitiveKeys = []string{
|
||||
"admin_token", "jwt_secret",
|
||||
"webdav_password", "s3_secret_access_key", "aws_session_token",
|
||||
}
|
||||
|
||||
// SensitiveMaskValue 敏感键掩码占位(管理端 get 展示用)。
|
||||
const SensitiveMaskValue = "******"
|
||||
|
||||
// IsSensitiveKey 判断键是否为敏感键(config get 必须屏蔽)。
|
||||
func IsSensitiveKey(key string) bool {
|
||||
for _, k := range SensitiveKeys {
|
||||
if k == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KVSchema 返回 v2 全量配置键元数据(键名/类型/默认值/边界/说明)。
|
||||
// 默认值必须与 config defaults() 一致(schema 同步测试保证)。
|
||||
func KVSchema() []config.KVSchemaEntry { return config.KVSchema() }
|
||||
|
||||
// KVSchemaByKey 以键名为索引查看 schema;未知键返回 nil。
|
||||
func KVSchemaByKey(key string) *config.KVSchemaEntry {
|
||||
for i := range config.KVSchema() {
|
||||
if config.KVSchema()[i].Key == key {
|
||||
return &config.KVSchema()[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package settings 提供数据库 settings KV 的运行时读写:
|
||||
// env(FCB_*)提供基线,DB KV 覆盖可变项;管理端修改后立即生效。
|
||||
package settings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/config"
|
||||
"filecodebox/internal/model"
|
||||
)
|
||||
|
||||
// settingsKey 数据库中的配置键(对齐参考实现)。
|
||||
const settingsKey = "settings"
|
||||
|
||||
// Manager 设置管理器:线程安全,缓存 KV 覆盖到内存。
|
||||
type Manager struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
|
||||
mu sync.RWMutex
|
||||
secret string // jwt_secret(频繁使用,单独缓存)
|
||||
initPwd string // admin_token 哈希(频繁使用,单独缓存)
|
||||
}
|
||||
|
||||
// NewManager 构造设置管理器并加载 DB KV。
|
||||
// ensure 默认配置行(首次启动时写入 settings 键)。
|
||||
func NewManager(ctx context.Context, db *gorm.DB, cfg *config.Config) (*Manager, error) {
|
||||
m := &Manager{db: db, cfg: cfg}
|
||||
if err := m.ensureSettingsRow(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.Reload(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ensureSettingsRow 首次启动时把默认安全配置写入 KV(对齐 ensure_settings_row)。
|
||||
func (m *Manager) ensureSettingsRow(ctx context.Context) error {
|
||||
var row model.KeyValue
|
||||
err := m.db.WithContext(ctx).Where(model.KeyValue{Key: settingsKey}).First(&row).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// 双方言:必须用 errors.Is 判定(sqlite 驱动错误链与字符串消息与 postgres 不同)
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
// 不存在:写入初始配置(不含 admin_token/jwt_secret,保持未初始化状态)
|
||||
initial := map[string]any{}
|
||||
raw, _ := json.Marshal(initial)
|
||||
row = model.KeyValue{Key: settingsKey, Value: strPtr(string(raw))}
|
||||
if err := m.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println("[settings] 系统尚未初始化,请在浏览器中打开站点并完成管理员密码设置")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload 从数据库加载 settings KV 并覆盖到运行时配置。
|
||||
func (m *Manager) Reload(ctx context.Context) error {
|
||||
var row model.KeyValue
|
||||
err := m.db.WithContext(ctx).Where(model.KeyValue{Key: settingsKey}).First(&row).Error
|
||||
if err != nil {
|
||||
// 行不存在时保持现有覆盖(双方言:errors.Is 判定)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
kv := map[string]any{}
|
||||
if row.Value != nil && *row.Value != "" {
|
||||
if err := json.Unmarshal([]byte(*row.Value), &kv); err != nil {
|
||||
log.Printf("[settings] settings KV 解析失败: %v", err)
|
||||
}
|
||||
}
|
||||
// 内部键不允许通过 KV 覆盖(_ 开头)
|
||||
safe := map[string]any{}
|
||||
for k, v := range kv {
|
||||
if len(k) > 0 && k[0] == '_' {
|
||||
continue
|
||||
}
|
||||
safe[k] = v
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.cfg.ApplyKV(safe)
|
||||
m.secret, _ = safe["jwt_secret"].(string)
|
||||
m.initPwd, _ = safe["admin_token"].(string)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 返回当前配置(只读使用;不要修改返回值)。
|
||||
func (m *Manager) Get() *config.Config { return m.cfg }
|
||||
|
||||
// SecretProvider 返回 jwt_secret 读取函数(JWT 中间件用)。
|
||||
func (m *Manager) SecretProvider() func() string {
|
||||
return func() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.secret
|
||||
}
|
||||
}
|
||||
|
||||
// IsInitialized 系统是否已完成初始化(管理员密码已设置且非默认密码)。
|
||||
func (m *Manager) IsInitialized() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.initPwd == "" {
|
||||
return false
|
||||
}
|
||||
// 旧版默认密码视为未初始化(对齐 LEGACY_DEFAULT_ADMIN_TOKEN 检查)
|
||||
return !verifyLegacyDefault(m.initPwd)
|
||||
}
|
||||
|
||||
// legacyDefaultToken 参考实现的旧默认管理员密码。
|
||||
const legacyDefaultToken = "FileCodeBox2023"
|
||||
|
||||
// verifyLegacyDefault 检查哈希是否对应旧默认密码。
|
||||
func verifyLegacyDefault(hashed string) bool {
|
||||
if hashed == "" {
|
||||
return false
|
||||
}
|
||||
return VerifyPassword(legacyDefaultToken, hashed)
|
||||
}
|
||||
|
||||
// UpdateKV 合并更新 settings KV(管理端保存配置)。
|
||||
func (m *Manager) UpdateKV(ctx context.Context, patch map[string]any) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// 读现有值
|
||||
var row model.KeyValue
|
||||
err := m.db.WithContext(ctx).Where(model.KeyValue{Key: settingsKey}).First(&row).Error
|
||||
kv := map[string]any{}
|
||||
if err == nil && row.Value != nil {
|
||||
_ = json.Unmarshal([]byte(*row.Value), &kv)
|
||||
}
|
||||
for k, v := range patch {
|
||||
if len(k) > 0 && k[0] == '_' {
|
||||
continue
|
||||
}
|
||||
kv[k] = v
|
||||
}
|
||||
raw, err := json.Marshal(kv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err == nil && row.ID > 0 {
|
||||
row.Value = strPtr(string(raw))
|
||||
return m.db.WithContext(ctx).Model(&row).Update("value", row.Value).Error
|
||||
}
|
||||
row = model.KeyValue{Key: settingsKey, Value: strPtr(string(raw))}
|
||||
return m.db.WithContext(ctx).Create(&row).Error
|
||||
}
|
||||
|
||||
// SystemStart 记录系统启动时间(对齐 sys_start 键)。
|
||||
func (m *Manager) SystemStart(ctx context.Context) {
|
||||
now := time.Now().UnixMilli()
|
||||
raw, _ := json.Marshal(now)
|
||||
_ = m.db.WithContext(ctx).Where(model.KeyValue{Key: "sys_start"}).
|
||||
Assign(model.KeyValue{Value: strPtr(string(raw))}).
|
||||
FirstOrCreate(&model.KeyValue{Key: "sys_start"}).Error
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
Reference in New Issue
Block a user