mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-06 06:29:35 +08:00
🐛 fix(security): 修复安全更新重检卡死与 Redis 密文兼容
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user