fix: harden registration with rate limiting, input validation, and security fixes

- Add per-IP rate limiter middleware (5 req/min) on /register endpoint
- Validate username (3-64 chars) and password (8-128 chars) with trim
- Use sentinel error ErrUsernameAlreadyExists instead of string matching
- Prevent TurnstileSecretKey exposure via admin settings API (json:"-")
- Skip json:"-" fields in UpdateAllSetting to avoid overwriting secrets
- Add SetTurnstileSecretKey setter for programmatic configuration
- Reuse package-level http.Client in Turnstile verification for connection pooling
- Add io.LimitReader to cap Turnstile response body size
- Log all Turnstile verification error paths for debugging
- Add invalidUsername/invalidPassword i18n keys to all 13 locales
This commit is contained in:
Sora39831 2026-04-03 02:02:25 +08:00
parent b4047cee54
commit 90665c92f4
19 changed files with 148 additions and 9 deletions

View file

@ -1,6 +1,7 @@
package controller package controller
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@ -8,6 +9,7 @@ import (
"time" "time"
"github.com/mhsanaei/3x-ui/v2/logger" "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/service"
"github.com/mhsanaei/3x-ui/v2/web/session" "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.GET("/logout", a.logout)
g.POST("/login", a.login) 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("/getTwoFactorEnable", a.getTwoFactorEnable)
g.POST("/getTurnstileSiteKey", a.getTurnstileSiteKey) 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")) pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.invalidFormData"))
return return
} }
// Trim whitespace
form.Username = strings.TrimSpace(form.Username)
form.Password = strings.TrimSpace(form.Password)
if form.Username == "" { if form.Username == "" {
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyUsername")) pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyUsername"))
return return
@ -138,6 +145,14 @@ func (a *IndexController) register(c *gin.Context) {
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyPassword")) pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyPassword"))
return 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 // Verify Turnstile token if site key is configured
turnstileSecretKey, err := a.settingService.GetTurnstileSecretKey() 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) err = a.userService.RegisterUser(form.Username, form.Password, &a.inboundService)
if err != nil { if err != nil {
errMsg := err.Error() if errors.Is(err, service.ErrUsernameAlreadyExists) {
if strings.Contains(errMsg, "already exists") {
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.userExists")) pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.userExists"))
return return
} }

View file

@ -106,7 +106,7 @@ type AllSetting struct {
// Registration settings // Registration settings
TurnstileSiteKey string `json:"turnstileSiteKey" form:"turnstileSiteKey"` 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. // CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.

View file

@ -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()
}
}

View file

@ -959,6 +959,10 @@ func (s *SettingService) GetTurnstileSecretKey() (string, error) {
return s.getString("turnstileSecretKey") return s.getString("turnstileSecretKey")
} }
func (s *SettingService) SetTurnstileSecretKey(value string) error {
return s.setString("turnstileSecretKey", value)
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := allSetting.CheckValid(); err != nil { if err := allSetting.CheckValid(); err != nil {
return err return err
@ -974,6 +978,9 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
fields := reflect_util.GetFields(t) fields := reflect_util.GetFields(t)
for _, field := range fields { for _, field := range fields {
key := field.Tag.Get("json") key := field.Tag.Get("json")
if key == "-" || key == "" {
continue
}
fieldV := v.FieldByName(field.Name) fieldV := v.FieldByName(field.Name)
settings[key] = fmt.Sprint(fieldV.Interface()) settings[key] = fmt.Sprint(fieldV.Interface())
} }

View file

@ -6,10 +6,14 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"time" "time"
"github.com/mhsanaei/3x-ui/v2/logger"
) )
const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
var turnstileClient = &http.Client{Timeout: 10 * time.Second}
type turnstileResponse struct { type turnstileResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
} }
@ -24,20 +28,22 @@ func VerifyTurnstile(secretKey, token, remoteIP string) bool {
form.Set("remoteip", remoteIP) form.Set("remoteip", remoteIP)
} }
client := &http.Client{Timeout: 10 * time.Second} resp, err := turnstileClient.PostForm(turnstileVerifyURL, form)
resp, err := client.PostForm(turnstileVerifyURL, form)
if err != nil { if err != nil {
logger.Warning("Turnstile verification request failed (network error):", err)
return false return false
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil { if err != nil {
logger.Warning("Turnstile verification failed to read response:", err)
return false return false
} }
var result turnstileResponse var result turnstileResponse
if err := json.Unmarshal(body, &result); err != nil { if err := json.Unmarshal(body, &result); err != nil {
logger.Warning("Turnstile verification failed to parse response:", err)
return false return false
} }
return result.Success return result.Success

View file

@ -15,6 +15,9 @@ import (
"gorm.io/gorm" "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. // UserService provides business logic for user management and authentication.
// It handles user creation, login, password management, and 2FA operations. // It handles user creation, login, password management, and 2FA operations.
type UserService struct { type UserService struct {
@ -152,8 +155,9 @@ func (s *UserService) RegisterUser(username string, password string, inboundServ
Role: "user", Role: "user",
} }
if err := tx.Create(user).Error; err != nil { if err := tx.Create(user).Error; err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "Duplicate") { errMsg := err.Error()
return errors.New("username already exists") if strings.Contains(errMsg, "UNIQUE constraint failed") || strings.Contains(errMsg, "Duplicate") {
return ErrUsernameAlreadyExists
} }
return err return err
} }

View file

@ -111,6 +111,8 @@
"successRegister" = "تم التسجيل بنجاح، يرجى تسجيل الدخول." "successRegister" = "تم التسجيل بنجاح، يرجى تسجيل الدخول."
"userExists" = "اسم المستخدم موجود بالفعل" "userExists" = "اسم المستخدم موجود بالفعل"
"errorRegister" = "فشل التسجيل" "errorRegister" = "فشل التسجيل"
"invalidUsername" = "يجب أن يكون اسم المستخدم بين 3 و 64 حرفًا"
"invalidPassword" = "يجب أن تكون كلمة المرور بين 8 و 128 حرفًا"
[pages.index] [pages.index]
"title" = "نظرة عامة" "title" = "نظرة عامة"

View file

@ -116,6 +116,8 @@
"successRegister" = "Registration successful, please log in." "successRegister" = "Registration successful, please log in."
"userExists" = "Username already exists" "userExists" = "Username already exists"
"errorRegister" = "Registration failed" "errorRegister" = "Registration failed"
"invalidUsername" = "Username must be 3-64 characters"
"invalidPassword" = "Password must be 8-128 characters"
[pages.index] [pages.index]
"title" = "Overview" "title" = "Overview"

View file

@ -111,6 +111,8 @@
"successRegister" = "Registro exitoso, por favor inicia sesión." "successRegister" = "Registro exitoso, por favor inicia sesión."
"userExists" = "El nombre de usuario ya existe" "userExists" = "El nombre de usuario ya existe"
"errorRegister" = "Error en el registro" "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] [pages.index]
"title" = "Estado del Sistema" "title" = "Estado del Sistema"

View file

@ -111,6 +111,8 @@
"successRegister" = "ثبت‌نام با موفقیت انجام شد، لطفاً وارد شوید." "successRegister" = "ثبت‌نام با موفقیت انجام شد، لطفاً وارد شوید."
"userExists" = "نام کاربری از قبل وجود دارد" "userExists" = "نام کاربری از قبل وجود دارد"
"errorRegister" = "ثبت نام ناموفق بود" "errorRegister" = "ثبت نام ناموفق بود"
"invalidUsername" = "نام کاربری باید بین ۳ تا ۶۴ کاراکتر باشد"
"invalidPassword" = "رمز عبور باید بین ۸ تا ۱۲۸ کاراکتر باشد"
[pages.index] [pages.index]
"title" = "نمای کلی" "title" = "نمای کلی"

View file

@ -111,6 +111,8 @@
"successRegister" = "Pendaftaran berhasil, silakan masuk." "successRegister" = "Pendaftaran berhasil, silakan masuk."
"userExists" = "Nama pengguna sudah ada" "userExists" = "Nama pengguna sudah ada"
"errorRegister" = "Pendaftaran gagal" "errorRegister" = "Pendaftaran gagal"
"invalidUsername" = "Nama pengguna harus 3-64 karakter"
"invalidPassword" = "Kata sandi harus 8-128 karakter"
[pages.index] [pages.index]
"title" = "Ikhtisar" "title" = "Ikhtisar"

View file

@ -111,6 +111,8 @@
"successRegister" = "登録が完了しました。ログインしてください。" "successRegister" = "登録が完了しました。ログインしてください。"
"userExists" = "ユーザー名は既に存在します" "userExists" = "ユーザー名は既に存在します"
"errorRegister" = "登録に失敗しました" "errorRegister" = "登録に失敗しました"
"invalidUsername" = "ユーザー名は3〜64文字で入力してください"
"invalidPassword" = "パスワードは8〜128文字で入力してください"
[pages.index] [pages.index]
"title" = "システムステータス" "title" = "システムステータス"

View file

@ -111,6 +111,8 @@
"successRegister" = "Registro bem-sucedido, por favor faça login." "successRegister" = "Registro bem-sucedido, por favor faça login."
"userExists" = "Nome de usuário já existe" "userExists" = "Nome de usuário já existe"
"errorRegister" = "Falha no registro" "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] [pages.index]
"title" = "Visão Geral" "title" = "Visão Geral"

View file

@ -111,6 +111,8 @@
"successRegister" = "Регистрация прошла успешно, пожалуйста, войдите." "successRegister" = "Регистрация прошла успешно, пожалуйста, войдите."
"userExists" = "Имя пользователя уже существует" "userExists" = "Имя пользователя уже существует"
"errorRegister" = "Ошибка регистрации" "errorRegister" = "Ошибка регистрации"
"invalidUsername" = "Имя пользователя должно содержать от 3 до 64 символов"
"invalidPassword" = "Пароль должен содержать от 8 до 128 символов"
[pages.index] [pages.index]
"title" = "Дашборд" "title" = "Дашборд"

View file

@ -111,6 +111,8 @@
"successRegister" = "Kayıt başarılı, lütfen giriş yapın." "successRegister" = "Kayıt başarılı, lütfen giriş yapın."
"userExists" = "Kullanıcı adı zaten mevcut" "userExists" = "Kullanıcı adı zaten mevcut"
"errorRegister" = "Kayıt başarısız" "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] [pages.index]
"title" = "Genel Bakış" "title" = "Genel Bakış"

View file

@ -111,6 +111,8 @@
"successRegister" = "Реєстрація пройшла успішно, будь ласка, увійдіть." "successRegister" = "Реєстрація пройшла успішно, будь ласка, увійдіть."
"userExists" = "Ім'я користувача вже існує" "userExists" = "Ім'я користувача вже існує"
"errorRegister" = "Помилка реєстрації" "errorRegister" = "Помилка реєстрації"
"invalidUsername" = "Ім'я користувача повинно містити від 3 до 64 символів"
"invalidPassword" = "Пароль повинен містити від 8 до 128 символів"
[pages.index] [pages.index]
"title" = "Огляд" "title" = "Огляд"

View file

@ -111,6 +111,8 @@
"successRegister" = "Đăng ký thành công, vui lòng đăng nhập." "successRegister" = "Đăng ký thành công, vui lòng đăng nhập."
"userExists" = "Tên người dùng đã tồn tại" "userExists" = "Tên người dùng đã tồn tại"
"errorRegister" = "Đăng ký thất bạ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] [pages.index]
"title" = "Trạng thái hệ thống" "title" = "Trạng thái hệ thống"

View file

@ -116,6 +116,8 @@
"successRegister" = "注册成功,请登录。" "successRegister" = "注册成功,请登录。"
"userExists" = "用户名已存在" "userExists" = "用户名已存在"
"errorRegister" = "注册失败" "errorRegister" = "注册失败"
"invalidUsername" = "用户名长度必须为3-64个字符"
"invalidPassword" = "密码长度必须为8-128个字符"
[pages.index] [pages.index]
"title" = "系统状态" "title" = "系统状态"

View file

@ -111,6 +111,8 @@
"successRegister" = "註冊成功,請登入。" "successRegister" = "註冊成功,請登入。"
"userExists" = "使用者名稱已存在" "userExists" = "使用者名稱已存在"
"errorRegister" = "註冊失敗" "errorRegister" = "註冊失敗"
"invalidUsername" = "使用者名稱長度必須為3-64個字元"
"invalidPassword" = "密碼長度必須為8-128個字元"
[pages.index] [pages.index]
"title" = "系統狀態" "title" = "系統狀態"