加固PostgreSQL连接参数与凭据脱敏并完成重复回归

This commit is contained in:
xuans 2026-09-16 21:09:21 +08:00
parent 16428f05b3
commit 781da9f32c
7 changed files with 278 additions and 42 deletions

View File

@ -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("数据库连接池配置无效")

View File

@ -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
}

View File

@ -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("校验错误不能包含输入凭据")
}
})
}

View File

@ -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 从凭据存储中读取外部数据库账号,在内存中构造连接配置。

View File

@ -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:") {

View File

@ -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 脱敏未覆盖查询参数、无效编码或带空格的密码")
}
}
}

View File

@ -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 部署仍未验收。