mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 08:35:00 +03:00
7367ff75cd
gotp decodes the stored secret with strict base32 and panics when that fails, so a 2FA secret saved through the settings API in lower case, grouped by spaces, or not base32 at all turned every login and every attempt to turn 2FA off into an HTTP 500. An empty secret had the opposite problem: the login check accepted the code of an empty HMAC key, which anyone can compute. VerifyWithSkew now normalizes the secret and verifies nothing for an empty or non-base32 one, so secrets already stored in a display form work again. Turning 2FA on through the settings update stores the normalized secret and refuses one no authenticator could match.
298 lines
9.2 KiB
Go
298 lines
9.2 KiB
Go
package service
|
|
|
|
import (
|
|
"path/filepath"
|
|
"regexp"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/xlzd/gotp"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
func TestResetSettingsRegeneratesSubscriptionPaths(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
for key, value := range map[string]string{
|
|
"subPath": "/sub/",
|
|
"subJsonPath": "/json/",
|
|
"subClashPath": "/clash/",
|
|
"webPort": "8443",
|
|
} {
|
|
if err := s.saveSetting(key, value); err != nil {
|
|
t.Fatalf("save %s: %v", key, err)
|
|
}
|
|
}
|
|
|
|
if err := s.ResetSettings(); err != nil {
|
|
t.Fatalf("ResetSettings: %v", err)
|
|
}
|
|
|
|
pathPattern := regexp.MustCompile(`^/[0-9a-z]{16}/$`)
|
|
paths := map[string]string{}
|
|
for _, key := range []string{"subPath", "subJsonPath", "subClashPath"} {
|
|
value, err := s.getString(key)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", key, err)
|
|
}
|
|
if !pathPattern.MatchString(value) {
|
|
t.Errorf("%s = %q, want /<16 lowercase alphanumeric characters>/", key, value)
|
|
}
|
|
paths[key] = value
|
|
}
|
|
if paths["subPath"] == paths["subJsonPath"] || paths["subPath"] == paths["subClashPath"] || paths["subJsonPath"] == paths["subClashPath"] {
|
|
t.Fatalf("subscription paths must be distinct: %v", paths)
|
|
}
|
|
if port, err := s.GetPort(); err != nil || port != 2053 {
|
|
t.Fatalf("web port after reset = %d, %v; want 2053", port, err)
|
|
}
|
|
}
|
|
|
|
func setupSettingTestDB(t *testing.T) {
|
|
t.Helper()
|
|
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
|
|
}
|
|
|
|
func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("discordBotToken", "discord-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
view, err := s.GetAllSettingView()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" || view.SmtpPassword != "" || view.DiscordBotToken != "" {
|
|
t.Fatalf("settings view leaked secrets: %#v", view)
|
|
}
|
|
if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken || !view.HasSmtpPassword || !view.HasDiscordBotToken {
|
|
t.Fatalf("settings view did not report configured secret flags: %#v", view)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("twoFactorEnable", "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("twoFactorToken", "JBSWY3DPEHPK3PXP"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("discordBotToken", "discord-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
view, err := s.GetAllSettingView()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings := &view.AllSetting
|
|
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
|
t.Fatalf("tg token = %q, want preserved secret", got)
|
|
}
|
|
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
|
|
t.Fatalf("ldap password = %q, want preserved secret", got)
|
|
}
|
|
if got, _ := s.GetTwoFactorToken(); got != "JBSWY3DPEHPK3PXP" {
|
|
t.Fatalf("2fa token = %q, want preserved secret", got)
|
|
}
|
|
if got, _ := s.GetSmtpPassword(); got != "smtp-secret" {
|
|
t.Fatalf("smtp password = %q, want preserved secret", got)
|
|
}
|
|
if got, _ := s.GetDiscordBotToken(); got != "discord-secret" {
|
|
t.Fatalf("discord token = %q, want preserved secret", got)
|
|
}
|
|
}
|
|
|
|
// Turning 2FA on stores the secret in the form the TOTP decoder takes, and refuses
|
|
// one that no authenticator code could ever match.
|
|
func TestUpdateAllSettingTwoFactorSecret(t *testing.T) {
|
|
cases := []struct {
|
|
name, stored, submitted, want string
|
|
wantErr bool
|
|
}{
|
|
{name: "canonical", submitted: "JBSWY3DPEHPK3PXP", want: "JBSWY3DPEHPK3PXP"},
|
|
{name: "lower case and grouped", submitted: "jbsw y3dp ehpk 3pxp", want: "JBSWY3DPEHPK3PXP"},
|
|
{name: "redacted keeps stored", stored: "ORIGINALSECRET234567", submitted: "", want: "ORIGINALSECRET234567"},
|
|
{name: "not base32", stored: "ORIGINALSECRET234567", submitted: "totp-secret", want: "ORIGINALSECRET234567", wantErr: true},
|
|
{name: "nothing stored", submitted: "", want: "", wantErr: true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
if err := s.SetTwoFactorToken(tc.stored); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
view, err := s.GetAllSettingView()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings := &view.AllSetting
|
|
settings.TwoFactorEnable = true
|
|
settings.TwoFactorToken = tc.submitted
|
|
err = s.UpdateAllSetting(settings, SecretClears{})
|
|
if (err != nil) != tc.wantErr {
|
|
t.Fatalf("UpdateAllSetting error = %v, wantErr %v", err, tc.wantErr)
|
|
}
|
|
if got, _ := s.GetTwoFactorToken(); got != tc.want {
|
|
t.Fatalf("stored 2FA secret = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.saveSetting("discordBotToken", "discord-secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
view, err := s.GetAllSettingView()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{SmtpPassword: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := s.GetSmtpPassword(); got != "" {
|
|
t.Fatalf("smtp password = %q, want cleared", got)
|
|
}
|
|
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
|
t.Fatalf("tg token = %q, unflagged secret must stay preserved", got)
|
|
}
|
|
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
|
|
t.Fatalf("ldap password = %q, unflagged secret must stay preserved", got)
|
|
}
|
|
if got, _ := s.GetDiscordBotToken(); got != "discord-secret" {
|
|
t.Fatalf("discord token = %q, unflagged secret must stay preserved", got)
|
|
}
|
|
|
|
view, err = s.GetAllSettingView()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if view.HasSmtpPassword {
|
|
t.Fatal("hasSmtpPassword must report false after clearing")
|
|
}
|
|
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{TgBotToken: true, LdapPassword: true, DiscordBotToken: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := s.GetTgBotToken(); got != "" {
|
|
t.Fatalf("tg token = %q, want cleared", got)
|
|
}
|
|
if got, _ := s.GetLdapPassword(); got != "" {
|
|
t.Fatalf("ldap password = %q, want cleared", got)
|
|
}
|
|
if got, _ := s.GetDiscordBotToken(); got != "" {
|
|
t.Fatalf("discord token = %q, want cleared", got)
|
|
}
|
|
|
|
view, err = s.GetAllSettingView()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if view.HasDiscordBotToken {
|
|
t.Fatal("hasDiscordBotToken must report false after clearing")
|
|
}
|
|
}
|
|
|
|
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
|
|
if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
|
|
t.Fatal("expected localhost URL to be blocked")
|
|
}
|
|
if got, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", true); err != nil || got != "http://127.0.0.1:8080/hook" {
|
|
t.Fatalf("allowPrivate result = %q, %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyTwoFactorCode(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
if err := s.saveSetting("twoFactorEnable", "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const token = "JBSWY3DPEHPK3PXP"
|
|
if err := s.saveSetting("twoFactorToken", token); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := s.VerifyTwoFactorCode(gotp.NewDefaultTOTP(token).Now()); err != nil {
|
|
t.Fatalf("valid code rejected: %v", err)
|
|
}
|
|
if err := s.VerifyTwoFactorCode(gotp.NewDefaultTOTP(token).AtTime(time.Now().Add(-30 * time.Second))); err != nil {
|
|
t.Fatalf("previous window code rejected: %v", err)
|
|
}
|
|
if err := s.VerifyTwoFactorCode("000000"); err == nil {
|
|
t.Fatal("invalid code accepted")
|
|
}
|
|
}
|
|
|
|
func TestGetSecret_FallbacksOnEmptyDatabaseSetting(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
s := &SettingService{}
|
|
if err := s.saveSetting("secret", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
secretBytes, err := s.GetSecret()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
secret := string(secretBytes)
|
|
if len(secret) != 32 {
|
|
t.Fatalf("secret length = %d, want 32-byte non-empty string", len(secret))
|
|
}
|
|
|
|
persisted, err := s.getString("secret")
|
|
if err != nil {
|
|
t.Fatalf("failed to retrieve persisted secret: %v", err)
|
|
}
|
|
if persisted != secret {
|
|
t.Fatalf("persisted secret = %q, want %q", persisted, secret)
|
|
}
|
|
}
|