From 4167597a152a70d8016094a14e64ffd826511e05 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:10:36 +0000 Subject: [PATCH] fix(database): replace the stored session secret once on upgrade The session cookie key is generated once and kept forever. Keys stored by older releases came from a weaker generator, and a stored value cannot be told apart from a current one, so drop it once through a seeder and let GetSecret store a fresh crypto/rand key on the next start. Admins log in again once after upgrading; fresh installs record the seeder up front. --- internal/database/db.go | 19 +++++++++++- internal/database/db_seed_test.go | 50 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/internal/database/db.go b/internal/database/db.go index 36569361e..f051e44cd 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1527,7 +1527,7 @@ func runSeeders(isUsersEmpty bool) error { } if empty && isUsersEmpty { - seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban"} + seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "SessionSecretRotation", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban"} for _, name := range seeders { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { return err @@ -1638,6 +1638,12 @@ func runSeeders(isUsersEmpty bool) error { } } + if !slices.Contains(seedersHistory, "SessionSecretRotation") { + if err := dropPreUpgradeSessionSecret(); err != nil { + return err + } + } + if !slices.Contains(seedersHistory, "OutboundRemovedKeysFix") { if err := migrateOutboundRemovedKeys(); err != nil { return err @@ -1899,6 +1905,17 @@ func clearLegacyProxySettings() error { }) } +// Secrets stored before v2.8.3 came from math/rand (a 31-bit clock seed up to v2.1.3); +// GetSecret draws a crypto/rand one on the next start. +func dropPreUpgradeSessionSecret() error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("key = ?", "secret").Delete(&model.Setting{}).Error; err != nil { + return err + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "SessionSecretRotation"}).Error + }) +} + func migrateOutboundRemovedKeys() error { var setting model.Setting err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error diff --git a/internal/database/db_seed_test.go b/internal/database/db_seed_test.go index c65e74bf5..85ceef985 100644 --- a/internal/database/db_seed_test.go +++ b/internal/database/db_seed_test.go @@ -107,6 +107,56 @@ func TestEnsureFirstUserCreatesOneWorkingCredential(t *testing.T) { } } +// Old releases drew the cookie-signing secret from a clock-seeded math/rand, and a +// stored value cannot tell its generator, so an upgrade must replace it exactly once. +func TestSessionSecretRotatesOnceOnUpgrade(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "x-ui.db") + if err := InitDB(dbPath); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + reopen := func() []string { + t.Helper() + if err := CloseDB(); err != nil { + t.Fatalf("CloseDB: %v", err) + } + if err := InitDB(dbPath); err != nil { + t.Fatalf("reopen InitDB: %v", err) + } + var secrets []string + if err := db.Model(&model.Setting{}).Where("key = ?", "secret").Pluck("value", &secrets).Error; err != nil { + t.Fatalf("read secret: %v", err) + } + return secrets + } + storeSecret := func(value string) { + t.Helper() + if err := db.Where("key = ?", "secret").Delete(&model.Setting{}).Error; err != nil { + t.Fatalf("clear secret: %v", err) + } + if err := db.Create(&model.Setting{Key: "secret", Value: value}).Error; err != nil { + t.Fatalf("store secret: %v", err) + } + } + + storeSecret("kept-on-a-fresh-install") + if got := reopen(); len(got) != 1 || got[0] != "kept-on-a-fresh-install" { + t.Fatalf("fresh install secret = %q after restart, want it kept", got) + } + + storeSecret("rfBd56ti2SMtYvSgD5xAV0YU99zampta") + if err := db.Where("seeder_name = ?", "SessionSecretRotation").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatalf("clear seeder history: %v", err) + } + if got := reopen(); len(got) != 0 { + t.Fatalf("secret = %q after upgrade, want the pre-upgrade secret dropped", got) + } + storeSecret("drawn-after-upgrade") + if got := reopen(); len(got) != 1 || got[0] != "drawn-after-upgrade" { + t.Fatalf("secret = %q, want the rotated secret kept so restarts do not log everyone out", got) + } +} + func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) { dbDir := t.TempDir() t.Setenv("XUI_DB_FOLDER", dbDir)