完善外部数据库凭据隔离与写入验证

This commit is contained in:
xuans 2026-09-16 14:42:09 +08:00
parent 2e1f654513
commit 1bb0199891
7 changed files with 472 additions and 16 deletions

View File

@ -65,27 +65,31 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
if err != nil {
return nil, fmt.Errorf("初始化日志失败:%w", err)
}
db, err := database.Open(ctx, cfg.Database)
if err != nil {
_ = logger.Close()
return nil, err
}
if err := database.Migrate(ctx, db.DB, cfg.Database.Driver); err != nil {
_ = db.Close()
_ = logger.Close()
return nil, err
}
secretStore, err := secretstore.Open(cfg.Paths.Config)
if err != nil {
_ = db.Close()
_ = logger.Close()
return nil, fmt.Errorf("初始化敏感信息存储失败:%w", err)
}
runtimeDatabaseConfig, err := database.ResolveConfig(ctx, cfg.Database, secretStore)
if err != nil {
_ = logger.Close()
return nil, err
}
db, err := database.Open(ctx, runtimeDatabaseConfig)
if err != nil {
_ = logger.Close()
return nil, err
}
if err := database.Migrate(ctx, db.DB, runtimeDatabaseConfig.Driver); err != nil {
_ = db.Close()
_ = logger.Close()
return nil, err
}
dnsTester := dnsprovider.NewTester(dnsprovider.NewLegoFactory())
dnsVerification := setup.NewDNSVerificationManager(dnsTester, secretStore)
repo := repository.NewSQLDomainRepository(db.DB, cfg.Database.Driver)
serverRepo := repository.NewSQLServerRepository(db.DB, cfg.Database.Driver)
userRepo := repository.NewSQLUserRepository(db.DB, cfg.Database.Driver)
repo := repository.NewSQLDomainRepository(db.DB, runtimeDatabaseConfig.Driver)
serverRepo := repository.NewSQLServerRepository(db.DB, runtimeDatabaseConfig.Driver)
userRepo := repository.NewSQLUserRepository(db.DB, runtimeDatabaseConfig.Driver)
if cfg.SetupReady && cfg.Admin.Username != "" && cfg.Admin.PasswordHash != "" {
if err := userRepo.EnsureSuperAdmin(ctx, repository.User{ID: "primary-super-admin", Username: cfg.Admin.Username, Email: cfg.Admin.Email, PasswordHash: cfg.Admin.PasswordHash}); err != nil {
_ = db.Close()
@ -93,7 +97,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
return nil, fmt.Errorf("初始化超级管理员用户失败:%w", err)
}
}
taskRepo := repository.NewSQLTaskRepository(db.DB, cfg.Database.Driver)
taskRepo := repository.NewSQLTaskRepository(db.DB, runtimeDatabaseConfig.Driver)
if recovered, err := taskRepo.RecoverInterruptedTasks(ctx, time.Now().UTC()); err != nil {
_ = db.Close()
_ = logger.Close()
@ -101,7 +105,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
} else if recovered > 0 {
logger.Warn("\u670d\u52a1\u91cd\u542f\u524d\u672a\u5b8c\u6210\u4efb\u52a1\u5df2\u6807\u8bb0\u4e3a\u5931\u8d25", "count", recovered)
}
auditRepo := repository.NewSQLAuditRepository(db.DB, cfg.Database.Driver)
auditRepo := repository.NewSQLAuditRepository(db.DB, runtimeDatabaseConfig.Driver)
if deleted, err := auditRepo.PruneExpiredAuditLogs(ctx, time.Now().UTC().AddDate(0, 0, -auditLogRetentionDays)); err != nil {
logger.Warn("启动时清理过期审计日志失败", "error", err)
} else if deleted > 0 {

View File

@ -478,6 +478,21 @@ func (c Config) Validate() error {
if strings.TrimSpace(c.Database.DSN) == "" {
return fmt.Errorf("数据库连接信息不能为空")
}
if c.Database.Driver == "sqlite" {
if strings.TrimSpace(c.Database.CredentialRef) != "" {
return fmt.Errorf("SQLite 不允许配置数据库凭据引用")
}
} else {
if strings.TrimSpace(c.Database.CredentialRef) == "" {
return fmt.Errorf("外部数据库必须配置加密凭据引用")
}
if !strings.HasPrefix(strings.TrimSpace(c.Database.CredentialRef), "secret://database/") {
return fmt.Errorf("数据库凭据引用必须使用 secret://database/ 前缀")
}
if databaseDSNContainsPassword(c.Database.Driver, c.Database.DSN) {
return fmt.Errorf("数据库连接信息不能包含密码,请使用加密凭据引用")
}
}
if c.Database.MaxOpenConns < 1 || c.Database.MaxIdleConns < 0 || c.Database.MaxIdleConns > c.Database.MaxOpenConns {
return fmt.Errorf("数据库连接池配置无效")
}
@ -603,6 +618,38 @@ func (c Config) Validate() error {
return nil
}
func databaseDSNContainsPassword(driver, dsn string) bool {
driver = strings.ToLower(strings.TrimSpace(driver))
dsn = strings.TrimSpace(dsn)
if dsn == "" {
return false
}
switch driver {
case "mysql":
at := strings.Index(dsn, "@")
if at < 0 {
return false
}
credentials := dsn[:at]
return strings.Contains(credentials, ":")
case "postgres":
if parsed, err := url.Parse(dsn); err == nil && (parsed.Scheme == "postgres" || parsed.Scheme == "postgresql") {
if parsed.User == nil {
return false
}
_, present := parsed.User.Password()
return present
}
for _, field := range strings.Fields(dsn) {
key, _, found := strings.Cut(field, "=")
if found && strings.EqualFold(strings.TrimSpace(key), "password") {
return true
}
}
}
return false
}
// ResetURL 返回通过主 Web 服务访问密码重置入口的公开地址。
func (c Config) ResetURL() string {
rawHost := strings.TrimSpace(c.Web.Hostname)

View File

@ -78,6 +78,41 @@ func TestValidateRejectsInvalidPort(t *testing.T) {
}
}
func TestValidateProtectsExternalDatabasePasswords(t *testing.T) {
tests := []struct {
name string
edit func(*Config)
}{
{
name: "MySQL password in DSN",
edit: func(cfg *Config) {
cfg.Database = DatabaseConfig{Driver: "mysql", DSN: "user:password@tcp(db.example.test:3306)/scert", CredentialRef: "secret://database/mysql"}
},
},
{
name: "PostgreSQL password in DSN",
edit: func(cfg *Config) {
cfg.Database = DatabaseConfig{Driver: "postgres", DSN: "postgres://user:password@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://database/postgres"}
},
},
{
name: "missing external database credential reference",
edit: func(cfg *Config) {
cfg.Database = DatabaseConfig{Driver: "mysql", DSN: "user@tcp(db.example.test:3306)/scert"}
},
},
}
for _, item := range tests {
t.Run(item.name, func(t *testing.T) {
cfg := Default(t.TempDir())
item.edit(&cfg)
if err := cfg.Validate(); err == nil {
t.Fatal("不安全外部数据库配置未被拒绝")
}
})
}
}
func TestResetURLAlwaysUsesWebHostnameWithoutPort(t *testing.T) {
cfg := Default(t.TempDir())
cfg.Web.Hostname = "scert.example.test"

View File

@ -0,0 +1,152 @@
package database
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
// CredentialReader 读取已加密保存的数据库用户名和密码。
// 数据库密码只能在调用期间留存在内存中,不能保存到普通配置文件。
type CredentialReader interface {
Get(context.Context, string) (map[string]string, error)
}
// NewCredentialReference 为外部数据库生成不可预测的加密凭据引用。
func NewCredentialReference() (string, error) {
buffer := make([]byte, 18)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("生成数据库凭据引用失败:%w", err)
}
return "secret://database/" + hex.EncodeToString(buffer), nil
}
// ValidateConfig 校验可持久化的数据库配置。外部数据库的 DSN 不能包含密码。
func ValidateConfig(cfg config.DatabaseConfig) error {
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
dsn := strings.TrimSpace(cfg.DSN)
credentialRef := strings.TrimSpace(cfg.CredentialRef)
if driver != "sqlite" && driver != "mysql" && driver != "postgres" {
return fmt.Errorf("数据库驱动无效:%s", cfg.Driver)
}
if dsn == "" {
return fmt.Errorf("数据库连接信息不能为空")
}
if driver == "sqlite" {
if credentialRef != "" {
return fmt.Errorf("SQLite 不允许配置数据库凭据引用")
}
return nil
}
if credentialRef == "" || !strings.HasPrefix(credentialRef, "secret://database/") {
return fmt.Errorf("外部数据库必须配置 secret://database/ 加密凭据引用")
}
if containsPassword(driver, dsn) {
return fmt.Errorf("数据库连接信息不能包含密码,请使用加密凭据引用")
}
if driver == "mysql" {
parsed, err := mysql.ParseDSN(dsn)
if err != nil {
return fmt.Errorf("解析 MySQL 连接信息失败:%w", err)
}
if strings.TrimSpace(parsed.User) == "" {
return fmt.Errorf("MySQL 连接信息必须包含用户名")
}
return nil
}
if _, err := normalizePostgresDSN(dsn); err != nil {
return err
}
return nil
}
// ResolveConfig 从凭据存储中读取外部数据库账号,在内存中构造连接配置。
// 返回值绝不能保存或记录到日志。
func ResolveConfig(ctx context.Context, cfg config.DatabaseConfig, reader CredentialReader) (config.DatabaseConfig, error) {
if err := ValidateConfig(cfg); err != nil {
return config.DatabaseConfig{}, err
}
resolved := cfg
if strings.EqualFold(resolved.Driver, "sqlite") {
return resolved, nil
}
if reader == nil {
return config.DatabaseConfig{}, fmt.Errorf("数据库敏感凭据存储未初始化")
}
credentials, err := reader.Get(ctx, resolved.CredentialRef)
if err != nil {
return config.DatabaseConfig{}, fmt.Errorf("读取数据库加密凭据失败:%w", err)
}
username := strings.TrimSpace(credentials["username"])
password, exists := credentials["password"]
if username == "" || !exists || password == "" {
return config.DatabaseConfig{}, fmt.Errorf("数据库加密凭据必须包含用户名和密码")
}
if strings.EqualFold(resolved.Driver, "mysql") {
dsn, err := withMySQLCredentials(resolved.DSN, username, password)
if err != nil {
return config.DatabaseConfig{}, err
}
resolved.DSN = dsn
return resolved, nil
}
dsn, err := withPostgresCredentials(resolved.DSN, username, password)
if err != nil {
return config.DatabaseConfig{}, err
}
resolved.DSN = dsn
return resolved, nil
}
func withMySQLCredentials(dsn, username, password string) (string, error) {
parsed, err := mysql.ParseDSN(strings.TrimSpace(dsn))
if err != nil {
return "", fmt.Errorf("解析 MySQL 连接信息失败:%w", err)
}
parsed.User = username
parsed.Passwd = password
parsed.ParseTime = true
parsed.Loc = time.UTC
return parsed.FormatDSN(), nil
}
func withPostgresCredentials(dsn, username, password string) (string, error) {
raw := strings.TrimSpace(dsn)
parsedURL, err := url.Parse(raw)
if err != nil || (parsedURL.Scheme != "postgres" && parsedURL.Scheme != "postgresql") {
return "", fmt.Errorf("PostgreSQL 数据库连接信息必须使用不含密码的 postgres:// URL")
}
parsedURL.User = url.UserPassword(username, password)
return parsedURL.String(), nil
}
func containsPassword(driver, dsn string) bool {
switch strings.ToLower(strings.TrimSpace(driver)) {
case "mysql":
at := strings.Index(dsn, "@")
return at >= 0 && strings.Contains(dsn[:at], ":")
case "postgres":
if parsed, err := url.Parse(dsn); err == nil && (parsed.Scheme == "postgres" || parsed.Scheme == "postgresql") {
if parsed.User == nil {
return false
}
_, exists := parsed.User.Password()
return exists
}
for _, field := range strings.Fields(dsn) {
key, _, found := strings.Cut(field, "=")
if found && strings.EqualFold(strings.TrimSpace(key), "password") {
return true
}
}
}
return false
}

View File

@ -0,0 +1,108 @@
package database
import (
"context"
"path/filepath"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
type memoryCredentialReader map[string]map[string]string
func (m memoryCredentialReader) Get(_ context.Context, reference string) (map[string]string, error) {
return m[reference], nil
}
func TestValidateConfigRequiresEncryptedExternalDatabaseCredentials(t *testing.T) {
base := config.Default(t.TempDir()).Database
for _, item := range []struct {
name string
cfg config.DatabaseConfig
}{
{name: "SQLite cannot have credential reference", cfg: config.DatabaseConfig{Driver: "sqlite", DSN: filepath.Join(t.TempDir(), "scert.db"), CredentialRef: "secret://database/unexpected"}},
{name: "MySQL requires reference", cfg: config.DatabaseConfig{Driver: "mysql", DSN: "user@tcp(db.example.test:3306)/scert"}},
{name: "MySQL rejects password in saved DSN", cfg: config.DatabaseConfig{Driver: "mysql", DSN: "user:password@tcp(db.example.test:3306)/scert", CredentialRef: "secret://database/test"}},
{name: "Postgres requires secure reference", cfg: config.DatabaseConfig{Driver: "postgres", DSN: "postgres://user@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://dns/not-database"}},
{name: "Postgres rejects password in saved DSN", cfg: config.DatabaseConfig{Driver: "postgres", DSN: "postgres://user:password@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://database/test"}},
} {
t.Run(item.name, func(t *testing.T) {
if err := ValidateConfig(item.cfg); err == nil {
t.Fatal("不安全数据库配置未被拒绝")
}
})
}
if err := ValidateConfig(base); err != nil {
t.Fatalf("有效 SQLite 配置被拒绝:%v", err)
}
}
func TestResolveConfigBuildsRuntimeDSNWithoutChangingPersistedConfig(t *testing.T) {
tests := []struct {
name string
cfg config.DatabaseConfig
wantSecret string
}{
{
name: "MySQL",
cfg: config.DatabaseConfig{Driver: "mysql", DSN: "user@tcp(db.example.test:3306)/scert?charset=utf8mb4", CredentialRef: "secret://database/mysql"},
wantSecret: "mysql-password",
},
{
name: "PostgreSQL",
cfg: config.DatabaseConfig{Driver: "postgres", DSN: "postgres://user@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://database/postgres"},
wantSecret: "postgres-password",
},
}
for _, item := range tests {
t.Run(item.name, func(t *testing.T) {
originalDSN := item.cfg.DSN
resolved, err := ResolveConfig(t.Context(), item.cfg, memoryCredentialReader{
item.cfg.CredentialRef: {"username": "user", "password": item.wantSecret},
})
if err != nil {
t.Fatalf("组装运行时连接信息失败:%v", err)
}
if !strings.Contains(resolved.DSN, item.wantSecret) {
t.Fatalf("运行时连接信息未包含内存凭据:%s", SanitizeDSN(item.cfg.Driver, resolved.DSN))
}
if item.cfg.DSN != originalDSN || strings.Contains(item.cfg.DSN, item.wantSecret) {
t.Fatalf("持久化配置被改写或包含密码:%+v", item.cfg)
}
})
}
}
func TestVerifyWritableSQLiteCleansTemporaryTable(t *testing.T) {
cfg := config.Default(t.TempDir()).Database
if err := VerifyWritable(t.Context(), cfg); err != nil {
t.Fatalf("SQLite 可写验证失败:%v", err)
}
db, err := Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var count int
if err := db.DB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE ?`, verificationTablePrefix+"%").Scan(&count); err != nil {
t.Fatalf("检查临时表清理失败:%v", err)
}
if count != 0 {
t.Fatalf("数据库验证遗留临时表:%d", count)
}
}
func TestNewCredentialReferenceHasDatabasePrefixAndIsUnique(t *testing.T) {
first, err := NewCredentialReference()
if err != nil {
t.Fatal(err)
}
second, err := NewCredentialReference()
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(first, "secret://database/") || first == second {
t.Fatalf("数据库凭据引用不安全:first=%s second=%s", first, second)
}
}

View File

@ -22,6 +22,9 @@ type Database struct {
}
func Open(ctx context.Context, cfg config.DatabaseConfig) (*Database, error) {
if err := ValidateConfigForOpen(cfg); err != nil {
return nil, err
}
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
if driver != "sqlite" && driver != "mysql" && driver != "postgres" {
return nil, fmt.Errorf("数据库驱动无效:%s", cfg.Driver)
@ -74,6 +77,22 @@ func Open(ctx context.Context, cfg config.DatabaseConfig) (*Database, error) {
return &Database{DB: db}, nil
}
// ValidateConfigForOpen 接受已在内存中补全凭据的外部数据库配置。
// 持久化配置必须使用 ValidateConfig;运行时配置由 ResolveConfig 生成。
func ValidateConfigForOpen(cfg config.DatabaseConfig) error {
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
if driver != "sqlite" && driver != "mysql" && driver != "postgres" {
return fmt.Errorf("数据库驱动无效:%s", cfg.Driver)
}
if strings.TrimSpace(cfg.DSN) == "" {
return fmt.Errorf("数据库连接信息不能为空")
}
if driver == "sqlite" && strings.TrimSpace(cfg.CredentialRef) != "" {
return fmt.Errorf("SQLite 不允许配置数据库凭据引用")
}
return nil
}
func configureSQLite(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, "PRAGMA busy_timeout = 5000"); err != nil {
return fmt.Errorf("配置 SQLite 忙等待失败:%w", err)

View File

@ -0,0 +1,91 @@
package database
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
const verificationTablePrefix = "scert_bootstrap_verify_"
// VerifyWritable 通过随机临时表执行创建、写入、读取和清理,验证数据库账号具备运行所需写权限。
// cfg 必须是 ResolveConfig 返回的仅内存配置,因其中可能含有数据库密码。
func VerifyWritable(ctx context.Context, cfg config.DatabaseConfig) (err error) {
db, err := Open(ctx, cfg)
if err != nil {
return err
}
defer db.Close()
tableName, err := newVerificationTableName()
if err != nil {
return err
}
value, err := newVerificationValue()
if err != nil {
return err
}
quotedTable := quoteIdentifier(cfg.Driver, tableName)
defer func() {
cleanupContext, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
cleanupErr := dropVerificationTable(cleanupContext, db.DB, quotedTable)
if cleanupErr != nil && err == nil {
err = fmt.Errorf("清理数据库验证临时表失败:%w", cleanupErr)
}
}()
if _, err := db.DB.ExecContext(ctx, "CREATE TABLE "+quotedTable+" (value TEXT NOT NULL)"); err != nil {
return fmt.Errorf("创建数据库验证临时表失败:%w", err)
}
placeholder := "?"
if strings.EqualFold(cfg.Driver, "postgres") {
placeholder = "$1"
}
if _, err := db.DB.ExecContext(ctx, "INSERT INTO "+quotedTable+" (value) VALUES ("+placeholder+")", value); err != nil {
return fmt.Errorf("写入数据库验证临时表失败:%w", err)
}
var actual string
if err := db.DB.QueryRowContext(ctx, "SELECT value FROM "+quotedTable+" LIMIT 1").Scan(&actual); err != nil {
return fmt.Errorf("读取数据库验证临时表失败:%w", err)
}
if actual != value {
return errors.New("数据库验证读取结果不一致")
}
return nil
}
func newVerificationTableName() (string, error) {
buffer := make([]byte, 12)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("生成数据库验证临时表名失败:%w", err)
}
return verificationTablePrefix + hex.EncodeToString(buffer), nil
}
func newVerificationValue() (string, error) {
buffer := make([]byte, 24)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("生成数据库验证随机值失败:%w", err)
}
return hex.EncodeToString(buffer), nil
}
func quoteIdentifier(driver, identifier string) string {
if strings.EqualFold(driver, "mysql") {
return "`" + identifier + "`"
}
return `"` + identifier + `"`
}
func dropVerificationTable(ctx context.Context, db *sql.DB, table string) error {
_, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS "+table)
return err
}