diff --git a/web/controller/index.go b/web/controller/index.go index 82373527..bc371f14 100644 --- a/web/controller/index.go +++ b/web/controller/index.go @@ -1,6 +1,7 @@ package controller import ( + "errors" "fmt" "net/http" "strings" @@ -8,6 +9,7 @@ import ( "time" "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/web/middleware" "github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/session" @@ -52,7 +54,7 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) { g.GET("/logout", a.logout) g.POST("/login", a.login) - g.POST("/register", a.register) + g.POST("/register", middleware.RateLimitMiddleware(5, time.Minute), a.register) g.POST("/getTwoFactorEnable", a.getTwoFactorEnable) g.POST("/getTurnstileSiteKey", a.getTurnstileSiteKey) } @@ -130,6 +132,11 @@ func (a *IndexController) register(c *gin.Context) { pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.invalidFormData")) return } + + // Trim whitespace + form.Username = strings.TrimSpace(form.Username) + form.Password = strings.TrimSpace(form.Password) + if form.Username == "" { pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyUsername")) return @@ -138,6 +145,14 @@ func (a *IndexController) register(c *gin.Context) { pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyPassword")) return } + if len(form.Username) < 3 || len(form.Username) > 64 { + pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.invalidUsername")) + return + } + if len(form.Password) < 8 || len(form.Password) > 128 { + pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.invalidPassword")) + return + } // Verify Turnstile token if site key is configured turnstileSecretKey, err := a.settingService.GetTurnstileSecretKey() @@ -154,8 +169,7 @@ func (a *IndexController) register(c *gin.Context) { err = a.userService.RegisterUser(form.Username, form.Password, &a.inboundService) if err != nil { - errMsg := err.Error() - if strings.Contains(errMsg, "already exists") { + if errors.Is(err, service.ErrUsernameAlreadyExists) { pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.userExists")) return } diff --git a/web/entity/entity.go b/web/entity/entity.go index dce37800..84f4272b 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -106,7 +106,7 @@ type AllSetting struct { // Registration settings TurnstileSiteKey string `json:"turnstileSiteKey" form:"turnstileSiteKey"` - TurnstileSecretKey string `json:"turnstileSecretKey" form:"turnstileSecretKey"` + TurnstileSecretKey string `json:"-" form:"-"` // server-side only, never sent to frontend } // CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values. diff --git a/web/middleware/ratelimit.go b/web/middleware/ratelimit.go new file mode 100644 index 00000000..99396766 --- /dev/null +++ b/web/middleware/ratelimit.go @@ -0,0 +1,82 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v2/web/entity" +) + +type rateEntry struct { + count int + lastSeen time.Time +} + +// RateLimitMiddleware returns a Gin middleware that limits requests per IP. +// maxRequests is the maximum number of requests allowed within the window. +func RateLimitMiddleware(maxRequests int, window time.Duration) gin.HandlerFunc { + var mu sync.Mutex + entries := make(map[string]*rateEntry) + + // Periodically evict stale entries to prevent unbounded memory growth + go func() { + ticker := time.NewTicker(window) + defer ticker.Stop() + for range ticker.C { + mu.Lock() + cutoff := time.Now().Add(-window * 2) + for ip, e := range entries { + if e.lastSeen.Before(cutoff) { + delete(entries, ip) + } + } + mu.Unlock() + } + }() + + return func(c *gin.Context) { + ip := c.GetHeader("X-Real-IP") + if ip == "" { + ip = c.GetHeader("X-Forwarded-For") + if ip != "" { + // Take the first IP from X-Forwarded-For + if idx := len(ip); idx > 0 { + for i, ch := range ip { + if ch == ',' { + ip = ip[:i] + break + } + } + } + } + } + if ip == "" { + ip = c.Request.RemoteAddr + } + + mu.Lock() + now := time.Now() + e, exists := entries[ip] + if !exists || now.Sub(e.lastSeen) > window { + entries[ip] = &rateEntry{count: 1, lastSeen: now} + mu.Unlock() + c.Next() + return + } + e.lastSeen = now + e.count++ + if e.count > maxRequests { + mu.Unlock() + c.JSON(http.StatusTooManyRequests, entity.Msg{ + Success: false, + Msg: "Too many requests", + }) + c.Abort() + return + } + mu.Unlock() + c.Next() + } +} diff --git a/web/service/setting.go b/web/service/setting.go index 651b9231..d8792bcc 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -959,6 +959,10 @@ func (s *SettingService) GetTurnstileSecretKey() (string, error) { return s.getString("turnstileSecretKey") } +func (s *SettingService) SetTurnstileSecretKey(value string) error { + return s.setString("turnstileSecretKey", value) +} + func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { if err := allSetting.CheckValid(); err != nil { return err @@ -974,6 +978,9 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { fields := reflect_util.GetFields(t) for _, field := range fields { key := field.Tag.Get("json") + if key == "-" || key == "" { + continue + } fieldV := v.FieldByName(field.Name) settings[key] = fmt.Sprint(fieldV.Interface()) } diff --git a/web/service/turnstile.go b/web/service/turnstile.go index 34d0c455..ff93f530 100644 --- a/web/service/turnstile.go +++ b/web/service/turnstile.go @@ -6,10 +6,14 @@ import ( "net/http" "net/url" "time" + + "github.com/mhsanaei/3x-ui/v2/logger" ) const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" +var turnstileClient = &http.Client{Timeout: 10 * time.Second} + type turnstileResponse struct { Success bool `json:"success"` } @@ -24,20 +28,22 @@ func VerifyTurnstile(secretKey, token, remoteIP string) bool { form.Set("remoteip", remoteIP) } - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.PostForm(turnstileVerifyURL, form) + resp, err := turnstileClient.PostForm(turnstileVerifyURL, form) if err != nil { + logger.Warning("Turnstile verification request failed (network error):", err) return false } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) if err != nil { + logger.Warning("Turnstile verification failed to read response:", err) return false } var result turnstileResponse if err := json.Unmarshal(body, &result); err != nil { + logger.Warning("Turnstile verification failed to parse response:", err) return false } return result.Success diff --git a/web/service/user.go b/web/service/user.go index 7afbc3d5..072e0345 100644 --- a/web/service/user.go +++ b/web/service/user.go @@ -15,6 +15,9 @@ import ( "gorm.io/gorm" ) +// ErrUsernameAlreadyExists is returned when a user tries to register with a taken username. +var ErrUsernameAlreadyExists = errors.New("username already exists") + // UserService provides business logic for user management and authentication. // It handles user creation, login, password management, and 2FA operations. type UserService struct { @@ -152,8 +155,9 @@ func (s *UserService) RegisterUser(username string, password string, inboundServ Role: "user", } if err := tx.Create(user).Error; err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "Duplicate") { - return errors.New("username already exists") + errMsg := err.Error() + if strings.Contains(errMsg, "UNIQUE constraint failed") || strings.Contains(errMsg, "Duplicate") { + return ErrUsernameAlreadyExists } return err } diff --git a/web/translation/translate.ar_EG.toml b/web/translation/translate.ar_EG.toml index c69fa7a0..4aa76642 100644 --- a/web/translation/translate.ar_EG.toml +++ b/web/translation/translate.ar_EG.toml @@ -111,6 +111,8 @@ "successRegister" = "تم التسجيل بنجاح، يرجى تسجيل الدخول." "userExists" = "اسم المستخدم موجود بالفعل" "errorRegister" = "فشل التسجيل" +"invalidUsername" = "يجب أن يكون اسم المستخدم بين 3 و 64 حرفًا" +"invalidPassword" = "يجب أن تكون كلمة المرور بين 8 و 128 حرفًا" [pages.index] "title" = "نظرة عامة" diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml index a60766c9..3a9c62d4 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -116,6 +116,8 @@ "successRegister" = "Registration successful, please log in." "userExists" = "Username already exists" "errorRegister" = "Registration failed" +"invalidUsername" = "Username must be 3-64 characters" +"invalidPassword" = "Password must be 8-128 characters" [pages.index] "title" = "Overview" diff --git a/web/translation/translate.es_ES.toml b/web/translation/translate.es_ES.toml index e407a0bb..06247d67 100644 --- a/web/translation/translate.es_ES.toml +++ b/web/translation/translate.es_ES.toml @@ -111,6 +111,8 @@ "successRegister" = "Registro exitoso, por favor inicia sesión." "userExists" = "El nombre de usuario ya existe" "errorRegister" = "Error en el registro" +"invalidUsername" = "El nombre de usuario debe tener entre 3 y 64 caracteres" +"invalidPassword" = "La contraseña debe tener entre 8 y 128 caracteres" [pages.index] "title" = "Estado del Sistema" diff --git a/web/translation/translate.fa_IR.toml b/web/translation/translate.fa_IR.toml index b21b2c32..94a5e86e 100644 --- a/web/translation/translate.fa_IR.toml +++ b/web/translation/translate.fa_IR.toml @@ -111,6 +111,8 @@ "successRegister" = "ثبت‌نام با موفقیت انجام شد، لطفاً وارد شوید." "userExists" = "نام کاربری از قبل وجود دارد" "errorRegister" = "ثبت نام ناموفق بود" +"invalidUsername" = "نام کاربری باید بین ۳ تا ۶۴ کاراکتر باشد" +"invalidPassword" = "رمز عبور باید بین ۸ تا ۱۲۸ کاراکتر باشد" [pages.index] "title" = "نمای کلی" diff --git a/web/translation/translate.id_ID.toml b/web/translation/translate.id_ID.toml index edb3fb95..d345403f 100644 --- a/web/translation/translate.id_ID.toml +++ b/web/translation/translate.id_ID.toml @@ -111,6 +111,8 @@ "successRegister" = "Pendaftaran berhasil, silakan masuk." "userExists" = "Nama pengguna sudah ada" "errorRegister" = "Pendaftaran gagal" +"invalidUsername" = "Nama pengguna harus 3-64 karakter" +"invalidPassword" = "Kata sandi harus 8-128 karakter" [pages.index] "title" = "Ikhtisar" diff --git a/web/translation/translate.ja_JP.toml b/web/translation/translate.ja_JP.toml index 3bddb7c1..a4936bbb 100644 --- a/web/translation/translate.ja_JP.toml +++ b/web/translation/translate.ja_JP.toml @@ -111,6 +111,8 @@ "successRegister" = "登録が完了しました。ログインしてください。" "userExists" = "ユーザー名は既に存在します" "errorRegister" = "登録に失敗しました" +"invalidUsername" = "ユーザー名は3〜64文字で入力してください" +"invalidPassword" = "パスワードは8〜128文字で入力してください" [pages.index] "title" = "システムステータス" diff --git a/web/translation/translate.pt_BR.toml b/web/translation/translate.pt_BR.toml index 6f876359..77bbd9a8 100644 --- a/web/translation/translate.pt_BR.toml +++ b/web/translation/translate.pt_BR.toml @@ -111,6 +111,8 @@ "successRegister" = "Registro bem-sucedido, por favor faça login." "userExists" = "Nome de usuário já existe" "errorRegister" = "Falha no registro" +"invalidUsername" = "O nome de usuário deve ter entre 3 e 64 caracteres" +"invalidPassword" = "A senha deve ter entre 8 e 128 caracteres" [pages.index] "title" = "Visão Geral" diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml index a973a0d2..ed544bbb 100644 --- a/web/translation/translate.ru_RU.toml +++ b/web/translation/translate.ru_RU.toml @@ -111,6 +111,8 @@ "successRegister" = "Регистрация прошла успешно, пожалуйста, войдите." "userExists" = "Имя пользователя уже существует" "errorRegister" = "Ошибка регистрации" +"invalidUsername" = "Имя пользователя должно содержать от 3 до 64 символов" +"invalidPassword" = "Пароль должен содержать от 8 до 128 символов" [pages.index] "title" = "Дашборд" diff --git a/web/translation/translate.tr_TR.toml b/web/translation/translate.tr_TR.toml index b510a686..a1857a2a 100644 --- a/web/translation/translate.tr_TR.toml +++ b/web/translation/translate.tr_TR.toml @@ -111,6 +111,8 @@ "successRegister" = "Kayıt başarılı, lütfen giriş yapın." "userExists" = "Kullanıcı adı zaten mevcut" "errorRegister" = "Kayıt başarısız" +"invalidUsername" = "Kullanıcı adı 3-64 karakter olmalıdır" +"invalidPassword" = "Şifre 8-128 karakter olmalıdır" [pages.index] "title" = "Genel Bakış" diff --git a/web/translation/translate.uk_UA.toml b/web/translation/translate.uk_UA.toml index 6e42d2aa..a7735af7 100644 --- a/web/translation/translate.uk_UA.toml +++ b/web/translation/translate.uk_UA.toml @@ -111,6 +111,8 @@ "successRegister" = "Реєстрація пройшла успішно, будь ласка, увійдіть." "userExists" = "Ім'я користувача вже існує" "errorRegister" = "Помилка реєстрації" +"invalidUsername" = "Ім'я користувача повинно містити від 3 до 64 символів" +"invalidPassword" = "Пароль повинен містити від 8 до 128 символів" [pages.index] "title" = "Огляд" diff --git a/web/translation/translate.vi_VN.toml b/web/translation/translate.vi_VN.toml index b2a54800..c37c34ca 100644 --- a/web/translation/translate.vi_VN.toml +++ b/web/translation/translate.vi_VN.toml @@ -111,6 +111,8 @@ "successRegister" = "Đăng ký thành công, vui lòng đăng nhập." "userExists" = "Tên người dùng đã tồn tại" "errorRegister" = "Đăng ký thất bại" +"invalidUsername" = "Tên người dùng phải từ 3-64 ký tự" +"invalidPassword" = "Mật khẩu phải từ 8-128 ký tự" [pages.index] "title" = "Trạng thái hệ thống" diff --git a/web/translation/translate.zh_CN.toml b/web/translation/translate.zh_CN.toml index e8e02d5c..3252dcf6 100644 --- a/web/translation/translate.zh_CN.toml +++ b/web/translation/translate.zh_CN.toml @@ -116,6 +116,8 @@ "successRegister" = "注册成功,请登录。" "userExists" = "用户名已存在" "errorRegister" = "注册失败" +"invalidUsername" = "用户名长度必须为3-64个字符" +"invalidPassword" = "密码长度必须为8-128个字符" [pages.index] "title" = "系统状态" diff --git a/web/translation/translate.zh_TW.toml b/web/translation/translate.zh_TW.toml index f2fb4539..9000c73d 100644 --- a/web/translation/translate.zh_TW.toml +++ b/web/translation/translate.zh_TW.toml @@ -111,6 +111,8 @@ "successRegister" = "註冊成功,請登入。" "userExists" = "使用者名稱已存在" "errorRegister" = "註冊失敗" +"invalidUsername" = "使用者名稱長度必須為3-64個字元" +"invalidPassword" = "密碼長度必須為8-128個字元" [pages.index] "title" = "系統狀態"