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.
This commit is contained in:
MHSanaei
2026-10-31 08:10:36 +00:00
parent c621ba9014
commit 4167597a15
2 changed files with 68 additions and 1 deletions
+18 -1
View File
@@ -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
+50
View File
@@ -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)