🐛 fix(security): 修复安全更新重检卡死与 Redis 密文兼容

This commit is contained in:
tianqijiuyun-latiao
2026-04-11 20:12:23 +08:00
parent 82e06bd94d
commit 1751e14d20
15 changed files with 585 additions and 54 deletions

View File

@@ -1,10 +1,13 @@
package app
import (
"errors"
"fmt"
"os"
"strings"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
)
func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (connection.ConnectionConfig, error) {
@@ -15,6 +18,9 @@ func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (conn
repo := newSavedConnectionRepository(a.configDir, a.secretStore)
view, err := repo.Find(config.ID)
if err != nil {
if shouldFallbackToInlineConnectionSecrets(config, err) {
return config, nil
}
return config, normalizeConnectionSecretResolutionError(config, err)
}
@@ -24,6 +30,9 @@ func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (conn
}
bundle, err := repo.loadSecretBundle(view)
if err != nil {
if shouldFallbackToInlineConnectionSecrets(config, err) {
return mergeInlineConnectionSecrets(base, config), nil
}
return base, normalizeConnectionSecretResolutionError(base, err)
}
resolved := mergeConnectionSecretBundleIntoConfig(base, bundle)
@@ -31,6 +40,57 @@ func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (conn
return resolved, nil
}
func shouldFallbackToInlineConnectionSecrets(config connection.ConnectionConfig, err error) bool {
if err == nil || !connectionConfigCarriesInlineSecrets(config) || secretstore.IsUnavailable(err) {
return false
}
if errors.Is(err, os.ErrNotExist) {
return true
}
lower := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(lower, "saved connection not found:")
}
func connectionConfigCarriesInlineSecrets(config connection.ConnectionConfig) bool {
return strings.TrimSpace(config.Password) != "" ||
strings.TrimSpace(config.SSH.Password) != "" ||
strings.TrimSpace(config.Proxy.Password) != "" ||
strings.TrimSpace(config.HTTPTunnel.Password) != "" ||
strings.TrimSpace(config.MySQLReplicaPassword) != "" ||
strings.TrimSpace(config.MongoReplicaPassword) != "" ||
strings.TrimSpace(config.URI) != "" ||
strings.TrimSpace(config.DSN) != ""
}
func mergeInlineConnectionSecrets(base connection.ConnectionConfig, inline connection.ConnectionConfig) connection.ConnectionConfig {
merged := base
if strings.TrimSpace(inline.Password) != "" {
merged.Password = inline.Password
}
if strings.TrimSpace(inline.SSH.Password) != "" {
merged.SSH.Password = inline.SSH.Password
}
if strings.TrimSpace(inline.Proxy.Password) != "" {
merged.Proxy.Password = inline.Proxy.Password
}
if strings.TrimSpace(inline.HTTPTunnel.Password) != "" {
merged.HTTPTunnel.Password = inline.HTTPTunnel.Password
}
if strings.TrimSpace(inline.MySQLReplicaPassword) != "" {
merged.MySQLReplicaPassword = inline.MySQLReplicaPassword
}
if strings.TrimSpace(inline.MongoReplicaPassword) != "" {
merged.MongoReplicaPassword = inline.MongoReplicaPassword
}
if strings.TrimSpace(inline.URI) != "" {
merged.URI = inline.URI
}
if strings.TrimSpace(inline.DSN) != "" {
merged.DSN = inline.DSN
}
return merged
}
func normalizeConnectionSecretResolutionError(config connection.ConnectionConfig, err error) error {
if err == nil {
return nil

View File

@@ -61,3 +61,78 @@ func TestResolveConnectionSecretsReturnsFriendlyMessageWhenSavedSecretSourceIsMi
t.Fatalf("expected a secret-specific error message, got %q", err.Error())
}
}
func TestResolveConnectionSecretsFallsBackToInlineSecretsWhenSavedConnectionIsMissing(t *testing.T) {
store := newFakeAppSecretStore()
app := NewAppWithSecretStore(store)
app.configDir = t.TempDir()
input := connection.ConnectionConfig{
ID: "legacy-inline",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "inline-secret",
DSN: "postgres://postgres:inline-secret@db.local/app",
}
resolved, err := app.resolveConnectionSecrets(input)
if err != nil {
t.Fatalf("expected inline secrets to be used as fallback, got error: %v", err)
}
if resolved.Password != "inline-secret" {
t.Fatalf("expected inline password to be preserved, got %q", resolved.Password)
}
if resolved.DSN != "postgres://postgres:inline-secret@db.local/app" {
t.Fatalf("expected inline DSN to be preserved, got %q", resolved.DSN)
}
}
func TestResolveConnectionSecretsFallsBackToInlineSecretsWhenSavedSecretBundleIsMissing(t *testing.T) {
store := newFakeAppSecretStore()
app := NewAppWithSecretStore(store)
app.configDir = t.TempDir()
view, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-inline-fallback",
Name: "Primary",
Config: connection.ConnectionConfig{
ID: "conn-inline-fallback",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "stored-secret",
DSN: "postgres://postgres:stored-secret@db.local/app",
},
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
if view.SecretRef == "" {
t.Fatal("expected saved connection to allocate a secret ref")
}
if err := store.Delete(view.SecretRef); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
resolved, err := app.resolveConnectionSecrets(connection.ConnectionConfig{
ID: "conn-inline-fallback",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "inline-secret",
DSN: "postgres://postgres:inline-secret@db.local/app",
})
if err != nil {
t.Fatalf("expected inline secrets to be used when secret bundle is missing, got error: %v", err)
}
if resolved.Password != "inline-secret" {
t.Fatalf("expected inline password to be preserved, got %q", resolved.Password)
}
if resolved.DSN != "postgres://postgres:inline-secret@db.local/app" {
t.Fatalf("expected inline DSN to be preserved, got %q", resolved.DSN)
}
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"math"
"net/url"
"strconv"
"strings"
"sync"
@@ -62,18 +63,78 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli
}
logger.Infof("创建 Redis 客户端实例缓存Key=%s", shortKey)
client := newRedisClientFunc()
if err := client.Connect(connectConfig); err != nil {
wrapped := wrapConnectError(effectiveConfig, err)
logger.Error(wrapped, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey)
client, connectedConfig, connectErr := connectRedisClientWithLegacyRootFallback(connectConfig)
if connectErr != nil {
wrapped := wrapConnectError(connectedConfig, connectErr)
logger.Error(wrapped, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(connectedConfig), shortKey)
return nil, wrapped
}
redisCache[key] = client
logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey)
logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(connectedConfig), shortKey)
return client, nil
}
func connectRedisClientWithLegacyRootFallback(config connection.ConnectionConfig) (redis.RedisClient, connection.ConnectionConfig, error) {
client := newRedisClientFunc()
if err := client.Connect(config); err == nil {
return client, config, nil
} else {
client.Close()
if !shouldRetryRedisWithClearedLegacyRoot(config, err) {
return nil, config, err
}
fallbackConfig := config
fallbackConfig.User = ""
logger.Warnf("Redis 使用用户名 root 认证失败,已按历史默认值回退为空用户名重试:%s", formatRedisConnSummary(config))
fallbackClient := newRedisClientFunc()
if retryErr := fallbackClient.Connect(fallbackConfig); retryErr != nil {
fallbackClient.Close()
return nil, fallbackConfig, retryErr
}
return fallbackClient, fallbackConfig, nil
}
}
func shouldRetryRedisWithClearedLegacyRoot(config connection.ConnectionConfig, err error) bool {
if err == nil || strings.ToLower(strings.TrimSpace(config.Type)) != "redis" {
return false
}
if strings.TrimSpace(config.User) != "root" {
return false
}
if _, ok := extractExplicitRedisUsername(config.URI); ok {
return false
}
lower := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(lower, "wrongpass") ||
strings.Contains(lower, "invalid username-password pair") ||
strings.Contains(lower, "auth failed") ||
strings.Contains(lower, "wrong number of arguments for 'auth' command") ||
strings.Contains(lower, "authentication failed")
}
func extractExplicitRedisUsername(rawURI string) (string, bool) {
trimmed := strings.TrimSpace(rawURI)
if trimmed == "" {
return "", false
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.User == nil {
return "", false
}
username := strings.TrimSpace(parsed.User.Username())
if username == "" {
return "", false
}
return username, true
}
func getRedisClientCacheKey(config connection.ConnectionConfig) string {
normalized := normalizeCacheKeyConfig(config)
b, _ := json.Marshal(normalized)

View File

@@ -1,6 +1,7 @@
package app
import (
"errors"
"testing"
"GoNavi-Wails/internal/connection"
@@ -92,6 +93,20 @@ func (c *capturingRedisClient) GetCurrentDB() int { return 0 }
func (c *capturingRedisClient) FlushDB() error { return nil }
type scriptedRedisClient struct {
capturingRedisClient
connectErr error
connectCalls *[]connection.ConnectionConfig
}
func (c *scriptedRedisClient) Connect(config connection.ConnectionConfig) error {
c.connectConfig = config
if c.connectCalls != nil {
*c.connectCalls = append(*c.connectCalls, config)
}
return c.connectErr
}
func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) {
testCases := []struct {
name string
@@ -215,6 +230,34 @@ func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) {
}
},
},
{
name: "explicit redis username from uri is preserved even when it is root",
savedConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
User: "root",
Password: "redis-secret",
URI: "redis://root:redis-secret@redis.local:6379/0",
},
runtimeConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
User: "root",
},
assertResolved: func(t *testing.T, got connection.ConnectionConfig) {
t.Helper()
if got.User != "root" {
t.Fatalf("expected RedisConnect to preserve explicit uri user root, got %q", got.User)
}
if got.URI != "redis://root:redis-secret@redis.local:6379/0" {
t.Fatalf("expected RedisConnect to restore saved redis uri, got %q", got.URI)
}
},
},
}
for _, testCase := range testCases {
@@ -256,3 +299,130 @@ func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) {
})
}
}
func TestRedisConnectPreservesExplicitRootUserWithoutURIWhenConnectSucceeds(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "redis-1",
Name: "Redis Saved",
Config: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
User: "root",
Password: "redis-secret",
},
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
CloseAllRedisClients()
connectCalls := make([]connection.ConnectionConfig, 0, 1)
client := &scriptedRedisClient{connectCalls: &connectCalls}
originalNewRedisClientFunc := newRedisClientFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newRedisClientFunc = originalNewRedisClientFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
CloseAllRedisClients()
}()
newRedisClientFunc = func() redislib.RedisClient {
return client
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
result := app.RedisConnect(connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
User: "root",
})
if !result.Success {
t.Fatalf("RedisConnect returned failure: %+v", result)
}
if len(connectCalls) != 1 {
t.Fatalf("expected exactly one Redis connect attempt, got %d", len(connectCalls))
}
if connectCalls[0].User != "root" {
t.Fatalf("expected RedisConnect to preserve explicit root user when connect succeeds, got %q", connectCalls[0].User)
}
}
func TestRedisConnectRetriesLegacyDefaultRootUserWithoutUsernameAfterAuthFailure(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "redis-1",
Name: "Redis Saved",
Config: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
User: "root",
Password: "redis-secret",
},
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
CloseAllRedisClients()
connectCalls := make([]connection.ConnectionConfig, 0, 2)
clients := []redislib.RedisClient{
&scriptedRedisClient{
connectErr: errors.New("WRONGPASS invalid username-password pair"),
connectCalls: &connectCalls,
},
&scriptedRedisClient{
connectCalls: &connectCalls,
},
}
clientIndex := 0
originalNewRedisClientFunc := newRedisClientFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newRedisClientFunc = originalNewRedisClientFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
CloseAllRedisClients()
}()
newRedisClientFunc = func() redislib.RedisClient {
if clientIndex >= len(clients) {
t.Fatalf("unexpected Redis client allocation #%d", clientIndex+1)
}
client := clients[clientIndex]
clientIndex++
return client
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
result := app.RedisConnect(connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
User: "root",
})
if !result.Success {
t.Fatalf("RedisConnect returned failure after fallback: %+v", result)
}
if len(connectCalls) != 2 {
t.Fatalf("expected RedisConnect to retry exactly once after auth failure, got %d attempts", len(connectCalls))
}
if connectCalls[0].User != "root" {
t.Fatalf("expected first Redis connect attempt to keep root user, got %q", connectCalls[0].User)
}
if connectCalls[1].User != "" {
t.Fatalf("expected fallback Redis connect attempt to clear legacy root user, got %q", connectCalls[1].User)
}
}