diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e06f6f1..9a59da0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -479,6 +479,11 @@ func (c Config) Validate() error { if databaseDSNContainsPassword(c.Database.Driver, c.Database.DSN) { return fmt.Errorf("数据库连接信息不能包含密码,请使用加密凭据引用") } + if c.Database.Driver == "postgres" { + if err := ValidatePostgresDSN(c.Database.DSN); err != nil { + return err + } + } } if c.Database.MaxOpenConns < 1 || c.Database.MaxIdleConns < 0 || c.Database.MaxIdleConns > c.Database.MaxOpenConns { return fmt.Errorf("数据库连接池配置无效") diff --git a/backend/internal/config/postgres.go b/backend/internal/config/postgres.go new file mode 100644 index 0000000..cf3e723 --- /dev/null +++ b/backend/internal/config/postgres.go @@ -0,0 +1,43 @@ +package config + +import ( + "errors" + "net/url" + "strings" +) + +// ValidatePostgresDSN 同时用于配置落盘和数据库连接前检查,不读取环境或外部文件。 +// 账号只能由加密凭据注入;查询参数不能改写验证过的主机、数据库或凭据来源。 +func ValidatePostgresDSN(dsn string) error { + parsed, err := url.Parse(strings.TrimSpace(dsn)) + if err != nil || (parsed.Scheme != "postgres" && parsed.Scheme != "postgresql") { + return errors.New("PostgreSQL 数据库连接信息必须使用不含账号密码的 postgres:// URL") + } + if parsed.User != nil { + return errors.New("PostgreSQL 用户名和密码只能保存到加密凭据中") + } + if strings.TrimSpace(parsed.Hostname()) == "" || strings.Trim(strings.TrimSpace(parsed.Path), "/") == "" { + return errors.New("PostgreSQL 连接信息必须包含主机和数据库") + } + if parsed.Opaque != "" || parsed.Fragment != "" { + return errors.New("PostgreSQL 连接信息不能包含片段或不透明地址") + } + query, err := url.ParseQuery(parsed.RawQuery) + if err != nil { + return errors.New("PostgreSQL 连接参数编码无效") + } + for key, values := range query { + if key == "" || key != strings.ToLower(strings.TrimSpace(key)) || len(values) != 1 { + return errors.New("PostgreSQL 连接参数必须为小写且不能重复") + } + switch key { + case "user", "password", "sslpassword", "host", "hostaddr", "port", "dbname", "database", + "service", "servicefile", "passfile": + return errors.New("PostgreSQL 查询参数不能包含账号密码或覆盖连接目标及凭据来源") + } + } + if query.Get("sslmode") != "verify-full" { + return errors.New("PostgreSQL 连接必须使用 sslmode=verify-full") + } + return nil +} diff --git a/backend/internal/config/postgres_test.go b/backend/internal/config/postgres_test.go new file mode 100644 index 0000000..f4cf374 --- /dev/null +++ b/backend/internal/config/postgres_test.go @@ -0,0 +1,75 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPostgresUnsafeDSNCannotBeSaved(t *testing.T) { + const base = "postgres://db.example.test:5432/scert?sslmode=verify-full" + for _, suffix := range []string{ + "&password=unsafe-secret", "&pass%77ord=unsafe-secret", "&user=other-user", + "&host=other.example.test", "&port=5433", "&dbname=other", "&database=other", + "&service=other", "&servicefile=/tmp/service", "&passfile=/tmp/passwords", + "&sslpassword=unsafe-secret", "&sslmode=disable", "&application_name=%zz", + "#unsafe-secret", "&Password=unsafe-secret", + } { + t.Run(suffix, func(t *testing.T) { + cfg := Default(t.TempDir()) + cfg.Database.Driver = "postgres" + cfg.Database.DSN = base + suffix + cfg.Database.CredentialRef = "secret://database/test" + path := filepath.Join(t.TempDir(), "scert.json") + err := cfg.Save(path) + if err == nil { + t.Fatal("含凭据或连接覆盖参数的配置未被拒绝") + } + if strings.Contains(err.Error(), "unsafe-secret") { + t.Fatal("配置错误泄露了输入凭据") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("无效配置不应写入磁盘") + } + }) + } +} + +func TestPostgresSafeConfigRoundTrips(t *testing.T) { + for _, dsn := range []string{ + "postgres://db.example.test:5432/scert?sslmode=verify-full", + "postgresql://[2001:db8::10]:5432/scert?sslmode=verify-full&application_name=scert&connect_timeout=10", + "postgres://db.example.test/scert?sslmode=verify-full&sslrootcert=%2Fetc%2Fscert%2Froot.pem", + } { + cfg := Default(t.TempDir()) + cfg.Database.Driver = "postgres" + cfg.Database.DSN = dsn + cfg.Database.CredentialRef = "secret://database/test" + path := filepath.Join(t.TempDir(), "scert.json") + if err := cfg.Save(path); err != nil { + t.Fatal(err) + } + loaded, err := Load(path) + if err != nil || loaded.Database != cfg.Database { + t.Fatal("合法 PostgreSQL 连接配置不能保存并重新加载") + } + } +} + +func FuzzValidatePostgresDSN(f *testing.F) { + for _, dsn := range []string{ + "postgres://db.example.test/scert?sslmode=verify-full", + "postgres://db.example.test/scert?sslmode=verify-full&password=fuzz-secret", + "postgres://db.example.test/scert?sslmode=verify-full&sslmode=disable", + "postgres://db.example.test/scert?sslmode=verify-full&password=%zz", + } { + f.Add(dsn) + } + f.Fuzz(func(t *testing.T, dsn string) { + err := ValidatePostgresDSN(dsn) + if err != nil && strings.Contains(err.Error(), "fuzz-secret") { + t.Fatal("校验错误不能包含输入凭据") + } + }) +} diff --git a/backend/internal/database/credentials.go b/backend/internal/database/credentials.go index 03d5625..1c57fa4 100644 --- a/backend/internal/database/credentials.go +++ b/backend/internal/database/credentials.go @@ -69,20 +69,7 @@ func ValidateConfig(cfg config.DatabaseConfig) error { } func validatePersistedPostgresDSN(dsn string) error { - parsed, err := url.Parse(strings.TrimSpace(dsn)) - if err != nil || (parsed.Scheme != "postgres" && parsed.Scheme != "postgresql") { - return fmt.Errorf("PostgreSQL 数据库连接信息必须使用不含账号密码的 postgres:// URL") - } - if strings.TrimSpace(parsed.Hostname()) == "" || strings.Trim(strings.TrimSpace(parsed.Path), "/") == "" { - return fmt.Errorf("PostgreSQL 连接信息必须包含主机和数据库") - } - if parsed.User != nil { - return fmt.Errorf("PostgreSQL 用户名和密码只能保存到加密凭据中") - } - if postgresSSLMode(dsn) != "verify-full" { - return fmt.Errorf("PostgreSQL 连接必须使用 sslmode=verify-full") - } - return nil + return config.ValidatePostgresDSN(dsn) } // ResolveConfig 从凭据存储中读取外部数据库账号,在内存中构造连接配置。 diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index c21c39c..b78aba0 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -121,9 +121,17 @@ func normalizeMySQLDSN(dsn string) (string, error) { } func normalizePostgresDSN(dsn string) (string, error) { + // 运行时账号位于 URL 的用户信息中,其他参数仍必须满足持久化安全边界。 + if raw, err := url.Parse(dsn); err == nil && (raw.Scheme == "postgres" || raw.Scheme == "postgresql") { + raw.User = nil + if err := config.ValidatePostgresDSN(raw.String()); err != nil { + return "", err + } + } parsed, err := pgx.ParseConfig(dsn) if err != nil { - return "", fmt.Errorf("解析 PostgreSQL 连接信息失败:%w", err) + // 驱动错误可能包含原始连接串,不能把尚未脱敏的输入带到日志或终端。 + return "", fmt.Errorf("解析 PostgreSQL 连接信息失败,请检查连接参数和证书文件") } if strings.TrimSpace(parsed.Host) == "" || strings.TrimSpace(parsed.Database) == "" || strings.TrimSpace(parsed.User) == "" { return "", fmt.Errorf("PostgreSQL 连接信息必须包含主机、数据库和用户名") @@ -152,7 +160,7 @@ func postgresSSLMode(dsn string) string { return "" } -// SanitizeDSN 返回可写入日志的数据库连接信息,不包含 MySQL 密码。 +// SanitizeDSN 返回可写入日志的数据库连接信息,不包含数据库密码。 func SanitizeDSN(driver, dsn string) string { switch strings.ToLower(strings.TrimSpace(driver)) { case "mysql": @@ -165,32 +173,13 @@ func SanitizeDSN(driver, dsn string) string { } return cfg.FormatDSN() case "postgres": - parsed, err := url.Parse(dsn) - if err == nil && (parsed.Scheme == "postgres" || parsed.Scheme == "postgresql") { - if parsed.User != nil { - username := parsed.User.Username() - if _, hasPassword := parsed.User.Password(); hasPassword { - parsed.User = url.UserPassword(username, "********") - } - } - return parsed.String() - } - return sanitizePostgresKeywordDSN(dsn) + // 关键字串支持空格和转义,URL 查询也可以承载凭据。 + // 不尝试在任意输入中定位密码,整体隐藏,避免无效编码或新参数导致漏脱敏。 + return "postgres://[连接信息已隐藏]" } return dsn } -func sanitizePostgresKeywordDSN(dsn string) string { - fields := strings.Fields(dsn) - for index, field := range fields { - key, _, found := strings.Cut(field, "=") - if found && strings.EqualFold(strings.TrimSpace(key), "password") { - fields[index] = key + "=********" - } - } - return strings.Join(fields, " ") -} - func ensureSQLiteDir(dsn string) error { path := dsn if strings.HasPrefix(path, "file:") { diff --git a/backend/internal/database/postgres_security_test.go b/backend/internal/database/postgres_security_test.go new file mode 100644 index 0000000..af7680d --- /dev/null +++ b/backend/internal/database/postgres_security_test.go @@ -0,0 +1,112 @@ +package database + +import ( + "context" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "github.com/jackc/pgx/v5" +) + +type countedCredentialReader struct { + calls int +} + +func (reader *countedCredentialReader) Get(context.Context, string) (map[string]string, error) { + reader.calls++ + return map[string]string{"username": "expected-user", "password": "expected-secret"}, nil +} + +func TestPostgresQueryCannotOverrideEncryptedCredentials(t *testing.T) { + for _, query := range []string{ + "password=untrusted-secret", "user=untrusted-user", "pass%77ord=untrusted-secret", + "sslpassword=untrusted-secret", "host=other.example.test", "dbname=other", + "sslmode=disable", "service=other", "passfile=/tmp/passwords", + } { + t.Run(query, func(t *testing.T) { + cfg := config.DatabaseConfig{ + Driver: "postgres", DSN: "postgres://db.example.test:5432/scert?sslmode=verify-full&" + query, + CredentialRef: "secret://database/test", + } + reader := &countedCredentialReader{} + if _, err := ResolveConfig(t.Context(), cfg, reader); err == nil { + t.Fatal("连接参数覆盖没有在读取凭据前被拒绝") + } + if reader.calls != 0 { + t.Fatal("校验无效连接参数不应读取加密凭据") + } + }) + } +} + +func TestPostgresRuntimeDSNRejectsOverridesAndHidesParseErrors(t *testing.T) { + for _, dsn := range []string{ + "postgres://user:LEAKME@db.example.test/scert?sslmode=verify-full&password=override", + "postgres://user:LEAKME@db.example.test/scert?sslmode=verify-full&host=other.example.test", + "postgres://user:LEAKME@db.example.test/scert?sslmode=verify-full&sslmode=disable", + "postgres://user:LEAKME@db.example.test/scert?sslmode=verify-full&connect_timeout=invalid", + "postgres://user:LEAKME%zz@db.example.test/scert?sslmode=verify-full", + "host=db.example.test user=user password='LEAKME' sslmode=verify-full port=invalid", + } { + result, err := normalizePostgresDSN(dsn) + if err == nil || result != "" { + t.Fatal("无效的运行时 PostgreSQL 连接信息未被拒绝") + } + if strings.Contains(err.Error(), "LEAKME") { + t.Fatal("解析错误泄露了运行时密码") + } + } +} + +func TestPostgresDriverQueryPrecedenceExplainsSecurityBoundary(t *testing.T) { + parsed, err := pgx.ParseConfig("postgres://expected-user:expected-secret@db.example.test/scert?sslmode=verify-full&user=overridden&password=overridden") + if err != nil { + t.Fatal("驱动测试夹具无效") + } + if parsed.User != "overridden" || parsed.Password != "overridden" { + t.Fatal("驱动参数优先级已变化,应重新复审防护边界") + } +} + +func TestPostgresResolvedCredentialsMatchDriver(t *testing.T) { + cfg := config.DatabaseConfig{ + Driver: "postgres", DSN: "postgres://db.example.test:5432/scert?sslmode=verify-full&application_name=scert", + CredentialRef: "secret://database/test", + } + const username = "user@example.test" + const password = "space @:/?#&% quoted'\\ secret" + resolved, err := ResolveConfig(t.Context(), cfg, memoryCredentialReader{ + cfg.CredentialRef: {"username": username, "password": password}, + }) + if err != nil { + t.Fatal(err) + } + parsed, err := pgx.ParseConfig(resolved.DSN) + if err != nil { + t.Fatal("驱动无法解析有效内存凭据") + } + if parsed.User != username || parsed.Password != password || parsed.Host != "db.example.test" || + parsed.Database != "scert" || parsed.TLSConfig == nil || parsed.TLSConfig.InsecureSkipVerify { + t.Fatal("驱动解析结果与受保护账号或验证目标不一致") + } +} + +func TestPostgresSanitizationDoesNotLeakNonUserinfoSecrets(t *testing.T) { + for _, dsn := range []string{ + "postgres://db.example.test/scert?password=LEAKME&sslmode=verify-full", + "postgres://db.example.test/scert?pass%77ord=LEAKME&sslmode=verify-full", + "postgres://db.example.test/scert?password=first&password=LEAKME", + "postgres://db.example.test/scert?sslpassword=LEAKME", + "postgres://db.example.test/scert?application_name=%zz&password=LEAKME", + "postgres://user:bad%zzLEAKME@db.example.test/scert", + "postgres://db.example.test/scert#LEAKME", + "host=db.example.test password='first LEAKME' dbname=scert", + "host=db.example.test password = 'LEAKME' dbname=scert", + "host=db.example.test password='first \\' LEAKME'", + } { + if result := SanitizeDSN("postgres", dsn); result == "" || strings.Contains(result, "LEAKME") { + t.Fatal("PostgreSQL 脱敏未覆盖查询参数、无效编码或带空格的密码") + } + } +} diff --git a/doc/首次IP初始化开发记录.md b/doc/首次IP初始化开发记录.md index 61aec1f..31cc233 100644 --- a/doc/首次IP初始化开发记录.md +++ b/doc/首次IP初始化开发记录.md @@ -34,9 +34,10 @@ - [✓] 初始化写请求拒绝跨源访问;前端最终提交重新校验所有步骤和令牌有效期,避免响应乱序、计时缓存及重复提交绕过。 - [✓] 临时 HTTPS、SQLite 初始化、正式 HTTPS 同端口切换、管理员登录和会话可用性通过隔离集成测试。 - [✓] 临时 TLS 启动提示输出实际证书的 SHA-256 指纹供可信运维通道核对,摘要一致性及日志不泄漏回归通过;浏览器人工核对尚未验收。 -- [~] 修复 pgx SQL 注入依赖漏洞并同步桌面端,继续清理可修补的间接依赖告警并重新回归。 -- [~] 增加由本机选择的外部防火墙管理策略,Linux systemd 明确选择该策略;不提权、不自动降级、不冒充端口已验证,补齐提示及回归测试。 -- [~] 修复关闭 Web 静态页面导致核心服务无法启动的回归;保留 API、桌面管理及续期服务,覆盖已初始化和首次切换两种场景。 +- [✓] 修复 pgx SQL 注入依赖漏洞并同步桌面端,升级可修补的 gRPC 间接依赖;Windows/Linux 后端和 Windows 桌面端扫描无可达漏洞,保留未导入的 openpgp 模块级告警说明。 +- [✓] 增加由本机选择的外部防火墙管理策略,Linux systemd 明确选择该策略;不提权、不自动降级、不冒充端口已验证,提示及本地回归通过,实机验证仍待完成。 +- [✓] 修复关闭 Web 静态页面导致核心服务无法启动的回归;保留 API、桌面管理及续期服务,覆盖已初始化和首次切换两种场景。 +- [✓] 修复 PostgreSQL URL 查询参数覆盖加密账号、连接目标和脱敏遗漏;新增落盘拒绝、运行时拒绝、特殊密码、驱动优先级及模糊测试,最新全量回归通过。 - [✓] 复审三库接入后的备份恢复兼容性:MySQL 接入加密凭据,PostgreSQL 未实现完整备份时明确拒绝,禁止漏备份却报告成功;内存凭据与拒绝操作的回归测试通过,不代表真实数据库备份恢复已验收。 - [!] PostgreSQL 完整备份恢复尚未实现,不能宣称三种数据库的全部运维能力均已完成。 - [!] 新版 Linux 发布包和桌面浏览器验收:本地独立服务启动及发布脚本执行此前被策略拒绝,未绕过限制。 @@ -58,10 +59,26 @@ git diff --check 桌面模块的 Go 测试、竞态检测、`go vet`、依赖校验及 `go mod tidy -diff` 通过;桌面端依赖已同步后端的 PostgreSQL 驱动等间接依赖。 -前端 `npm.cmd test` 共 38 项通过,覆盖初始化令牌兑换、三数据库表单、完整向导提交、通知映射、过期令牌、错误响应及 DNS 等待状态。前端生产构建通过,产物写入新的 `.runtime/frontend-bootstrap-20260916-2030`,未清理旧产物;生产依赖审计未报告漏洞。 +前端 `npm.cmd test` 共 39 项通过,覆盖初始化令牌兑换、三数据库表单、完整向导提交、通知映射、过期令牌、错误响应、DNS 等待状态及外部防火墙警告。最新前端生产构建通过,产物写入新的 `.runtime/frontend-bootstrap-20260916-2104`,未清理旧产物;生产依赖审计未报告漏洞。 另外,设置 `GOOS=linux`、`GOARCH=amd64`、`CGO_ENABLED=0` 后执行 `go build -p 1 ./...` 通过,仅验证 Linux 目标源码可构建,没有生成或部署本轮 Linux 发布包。2026-09-16 20:35 已收齐新增维护检查后的全量测试、竞态检测、静态检查和 Linux 交叉构建结果,均通过。 +2026-09-16 20:57 再次收齐外部防火墙策略、关闭 Web 后保留核心服务以及依赖升级后的后端全量测试、全量竞态检测、静态检查、依赖校验与 Linux 交叉构建结果,均通过。Linux `servicehost` 测试二进制交叉编译通过,但没有在 Linux 执行。 + +2026-09-16 21:09 收齐 PostgreSQL 连接串安全修复后的最新全量结果:后端普通测试、全量竞态检测、`go vet`、`go mod verify`、`go mod tidy -diff`、Linux amd64 交叉构建和差异检查均通过;桌面模块普通测试、竞态检测、静态检查及依赖一致性校验通过。修改文件经只读检查确认为 UTF-8 无 BOM。本轮仍未生成新版 Linux 发布包,未进行浏览器或远程实机验收。 + +### 安全依赖与重复验证 + +- 后端和桌面模块实际使用 Go `1.26.6`,不是系统目录可能显示的其他版本。 +- pgx 从 `v5.8.0` 升至 `v5.9.2`,修复 `GO-2026-5004`;gRPC 从 `v1.83.1` 升至 `v1.83.2`,修复 `GO-2026-6443`,并同步 `golang.org/x/net v0.58.0`。 +- 最新 Windows/Linux 后端安全扫描为零可达漏洞、零导入包漏洞;Windows 桌面端同样通过。仍有 `GO-2026-5932` 模块级告警:`golang.org/x/crypto/openpgp` 已停止维护且无修复版本。后端导入图未包含该包,不能表述为所有依赖零告警。 +- 认证及桌面切换的七组回归以 `-race -count=10` 通过,包括不完整会话失败关闭、512 协程登录准入、容量饱和恢复、来源容量及在途失败上限、128 个本机 HTTP 请求突发、并发激活和幂等关闭、关闭取消在途初始化。 +- 阿里云授权、唯一权威区识别、拒绝父区回退、保护域名、撤权重检和精确清理相关测试以 `-race -count=10` 通过,均使用测试替身,没有调用真实云 API。 +- SQLite 临时 HTTPS 初始化及正式服务切换、取消启动清理以 `-race -count=3` 通过;完成流程覆盖 `automatic-fixture` 和真实 `external` 管理器,后者不执行系统防火墙命令。 +- PostgreSQL 连接串校验模糊测试运行 20 秒、执行 833068 次,无失败;该结果仅说明测试断言和抗异常输入通过,不代表真实 PostgreSQL 服务验收。 + +覆盖率命令首次因 PowerShell 未正确传递未加引号的 `-coverprofile` 参数失败;给完整参数加引号后重跑通过。报告位于 `.runtime/postgres-config-security-20260916-2107.out`,配置和数据库两包总语句覆盖率为 67.3%,不能据此称为全面覆盖。 + 测试使用独立临时目录和隔离 SQLite 数据库;阿里云验证、证书签发和防火墙由测试替身模拟,没有修改真实 DNS 或主机防火墙。 MySQL/PostgreSQL 参数、验证状态和凭据处理有自动化覆盖,但这不等于本轮完成两个真实数据库服务的连接、迁移和权限验收。HTTP 接口测试不等于浏览器验收。通知配置测试不等于实际邮件投递。 @@ -92,6 +109,14 @@ HTTPS 集成测试只连接本机回环地址,严格校验证书及名称, PostgreSQL 当前可选择初始化,但原完整备份实现只有 SQLite/MySQL。本轮先让 PostgreSQL 的 `backup`、`restore` 明确失败,在创建输出或替换目标前阻止不完整备份被当作成功。完整实现及真实恢复演练仍是生产准入阻塞,不能把这一防护当作备份功能开发完成。 +### PostgreSQL 连接串安全边界 + +驱动实际会让 URL 查询中的 `user`、`password` 覆盖用户信息部分。旧检查仅检查用户信息,导致查询中的明文凭据可以保存,连接时还可能覆盖已解密的账号。本轮先通过失败回归复现,再把校验集中到 `config.ValidatePostgresDSN`,配置保存、凭据解析和 URL 运行时连接共用边界。 + +拒绝查询中的账号、密码、客户端私钥密码、目标主机/端口/数据库覆盖及额外凭据文件来源;拒绝重复参数、混合大小写参数、无效编码和片段。继续允许正常 TLS 根证书、连接超时和应用名称等参数,合法配置可保存并重新加载,特殊字符密码经 URL 编码后与驱动解析值一致。 + +PostgreSQL 连接串日志辅助函数改为整体隐藏,解析失败不返回驱动原始连接串。拒绝无效配置时不读取凭据、不写入配置文件。这里的修复没有实现 PostgreSQL 备份恢复,也不证明数据库环境、连接权限和证书已在实机验收。 + ### 验收与操作边界 - 新启动链路已接入 `scert serve`,但独立服务的实际页面渲染和新版本 Linux 部署仍未验收。