diff --git a/web/assets/js/axios-init.js b/web/assets/js/axios-init.js index f0b0f4be..c44c0647 100644 --- a/web/assets/js/axios-init.js +++ b/web/assets/js/axios-init.js @@ -3,6 +3,12 @@ axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; axios.interceptors.request.use( (config) => { + config.headers = config.headers || {}; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const method = (config.method || 'get').toUpperCase(); + if (csrfToken && !['GET', 'HEAD', 'OPTIONS', 'TRACE'].includes(method)) { + config.headers['X-CSRF-Token'] = csrfToken; + } if (config.data instanceof FormData) { config.headers['Content-Type'] = 'multipart/form-data'; } else { diff --git a/web/controller/api.go b/web/controller/api.go index 57d2e4cb..e99a26d2 100644 --- a/web/controller/api.go +++ b/web/controller/api.go @@ -3,6 +3,7 @@ package controller import ( "net/http" + "github.com/mhsanaei/3x-ui/v2/web/middleware" "github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/session" @@ -39,6 +40,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom // Main API group api := g.Group("/panel/api") api.Use(a.checkAPIAuth) + api.Use(middleware.CSRFMiddleware()) // Inbounds API inbounds := api.Group("/inbounds") diff --git a/web/controller/index.go b/web/controller/index.go index 14791543..d3c58da8 100644 --- a/web/controller/index.go +++ b/web/controller/index.go @@ -1,12 +1,12 @@ package controller import ( - "fmt" "net/http" "text/template" "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" @@ -41,8 +41,8 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) { g.GET("/", a.index) g.GET("/logout", a.logout) - g.POST("/login", a.login) - g.POST("/getTwoFactorEnable", a.getTwoFactorEnable) + g.POST("/login", middleware.CSRFMiddleware(), a.login) + g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable) } // index handles the root route, redirecting logged-in users to the panel or showing the login page. @@ -71,28 +71,51 @@ func (a *IndexController) login(c *gin.Context) { return } - user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode) - timeStr := time.Now().Format("2006-01-02 15:04:05") + remoteIP := getRemoteIp(c) safeUser := template.HTMLEscapeString(form.Username) - safePass := template.HTMLEscapeString(form.Password) - - if user == nil { - logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c)) - - notifyPass := safePass - - if checkErr != nil && checkErr.Error() == "invalid 2fa code" { - translatedError := a.tgbot.I18nBot("tgbot.messages.2faFailed") - notifyPass = fmt.Sprintf("*** (%s)", translatedError) - } - - a.tgbot.UserLoginNotify(safeUser, notifyPass, getRemoteIp(c), timeStr, 0) + timeStr := time.Now().Format("2006-01-02 15:04:05") + if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok { + reason := "too many failed attempts" + logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339)) + a.tgbot.UserLoginNotify(service.LoginAttempt{ + Username: safeUser, + IP: remoteIP, + Time: timeStr, + Status: service.LoginFail, + Reason: reason, + }) pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword")) return } - logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, getRemoteIp(c)) - a.tgbot.UserLoginNotify(safeUser, ``, getRemoteIp(c), timeStr, 1) + user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode) + + if user == nil { + reason := loginFailureReason(checkErr) + if blockedUntil, blocked := defaultLoginLimiter.registerFailure(remoteIP, form.Username); blocked { + logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339)) + } else { + logger.Warningf("failed login: username=%q, IP=%q, reason=%q", safeUser, remoteIP, reason) + } + a.tgbot.UserLoginNotify(service.LoginAttempt{ + Username: safeUser, + IP: remoteIP, + Time: timeStr, + Status: service.LoginFail, + Reason: reason, + }) + pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword")) + return + } + + defaultLoginLimiter.registerSuccess(remoteIP, form.Username) + logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, remoteIP) + a.tgbot.UserLoginNotify(service.LoginAttempt{ + Username: safeUser, + IP: remoteIP, + Time: timeStr, + Status: service.LoginSuccess, + }) if err := session.SetLoginUser(c, user); err != nil { logger.Warning("Unable to save session:", err) @@ -103,6 +126,13 @@ func (a *IndexController) login(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil) } +func loginFailureReason(err error) string { + if err != nil && err.Error() == "invalid 2fa code" { + return "invalid 2FA code" + } + return "invalid credentials" +} + // logout handles user logout by clearing the session and redirecting to the login page. func (a *IndexController) logout(c *gin.Context) { user := session.GetLoginUser(c) diff --git a/web/controller/login_limiter.go b/web/controller/login_limiter.go new file mode 100644 index 00000000..1694db99 --- /dev/null +++ b/web/controller/login_limiter.go @@ -0,0 +1,99 @@ +package controller + +import ( + "strings" + "sync" + "time" +) + +const ( + loginLimitMaxFailures = 5 + loginLimitWindow = 5 * time.Minute + loginLimitCooldown = 15 * time.Minute +) + +var defaultLoginLimiter = newLoginLimiter(loginLimitMaxFailures, loginLimitWindow, loginLimitCooldown) + +type loginLimiter struct { + mu sync.Mutex + now func() time.Time + maxFailures int + window time.Duration + cooldown time.Duration + attempts map[string]*loginLimitRecord +} + +type loginLimitRecord struct { + failures []time.Time + blockedUntil time.Time +} + +func newLoginLimiter(maxFailures int, window, cooldown time.Duration) *loginLimiter { + return &loginLimiter{ + now: time.Now, + maxFailures: maxFailures, + window: window, + cooldown: cooldown, + attempts: make(map[string]*loginLimitRecord), + } +} + +func (l *loginLimiter) allow(ip, username string) (time.Time, bool) { + l.mu.Lock() + defer l.mu.Unlock() + + key := loginLimitKey(ip, username) + record := l.attempts[key] + if record == nil { + return time.Time{}, true + } + now := l.now() + if now.Before(record.blockedUntil) { + return record.blockedUntil, false + } + record.blockedUntil = time.Time{} + record.failures = pruneLoginFailures(record.failures, now.Add(-l.window)) + if len(record.failures) == 0 { + delete(l.attempts, key) + } + return time.Time{}, true +} + +func (l *loginLimiter) registerFailure(ip, username string) (time.Time, bool) { + l.mu.Lock() + defer l.mu.Unlock() + + key := loginLimitKey(ip, username) + record := l.attempts[key] + if record == nil { + record = &loginLimitRecord{} + l.attempts[key] = record + } + now := l.now() + record.failures = pruneLoginFailures(record.failures, now.Add(-l.window)) + record.failures = append(record.failures, now) + if len(record.failures) >= l.maxFailures { + record.failures = nil + record.blockedUntil = now.Add(l.cooldown) + return record.blockedUntil, true + } + return time.Time{}, false +} + +func (l *loginLimiter) registerSuccess(ip, username string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.attempts, loginLimitKey(ip, username)) +} + +func loginLimitKey(ip, username string) string { + return strings.TrimSpace(ip) + "\x00" + strings.ToLower(strings.TrimSpace(username)) +} + +func pruneLoginFailures(failures []time.Time, cutoff time.Time) []time.Time { + keepFrom := 0 + for keepFrom < len(failures) && failures[keepFrom].Before(cutoff) { + keepFrom++ + } + return failures[keepFrom:] +} diff --git a/web/controller/login_limiter_test.go b/web/controller/login_limiter_test.go new file mode 100644 index 00000000..cc9cd80e --- /dev/null +++ b/web/controller/login_limiter_test.go @@ -0,0 +1,74 @@ +package controller + +import ( + "testing" + "time" +) + +func TestLoginLimiterBlocksAfterConfiguredFailures(t *testing.T) { + now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC) + limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute) + limiter.now = func() time.Time { return now } + + for i := 0; i < 4; i++ { + if _, blocked := limiter.registerFailure("192.0.2.10", "Admin"); blocked { + t.Fatalf("failure %d should not block yet", i+1) + } + if _, ok := limiter.allow("192.0.2.10", "admin"); !ok { + t.Fatalf("failure %d should still allow login attempts", i+1) + } + } + + blockedUntil, blocked := limiter.registerFailure("192.0.2.10", "ADMIN") + if !blocked { + t.Fatal("fifth failure should start cooldown") + } + if want := now.Add(15 * time.Minute); !blockedUntil.Equal(want) { + t.Fatalf("blocked until %s, want %s", blockedUntil, want) + } + if _, ok := limiter.allow("192.0.2.10", "admin"); ok { + t.Fatal("login should be blocked during cooldown") + } + + now = blockedUntil + if _, ok := limiter.allow("192.0.2.10", "admin"); !ok { + t.Fatal("login should be allowed after cooldown") + } +} + +func TestLoginLimiterPrunesOldFailuresAndResetsOnSuccess(t *testing.T) { + now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC) + limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute) + limiter.now = func() time.Time { return now } + + for i := 0; i < 4; i++ { + limiter.registerFailure("192.0.2.10", "admin") + } + now = now.Add(6 * time.Minute) + if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked { + t.Fatal("old failures should be pruned outside the rolling window") + } + + limiter.registerSuccess("192.0.2.10", "admin") + for i := 0; i < 4; i++ { + if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked { + t.Fatalf("success should reset previous failures; failure %d blocked", i+1) + } + } +} + +func TestLoginLimiterSeparatesIPAndUsername(t *testing.T) { + now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC) + limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute) + limiter.now = func() time.Time { return now } + + for i := 0; i < 5; i++ { + limiter.registerFailure("192.0.2.10", "admin") + } + if _, ok := limiter.allow("192.0.2.11", "admin"); !ok { + t.Fatal("different IP should not be blocked") + } + if _, ok := limiter.allow("192.0.2.10", "other-admin"); !ok { + t.Fatal("different username should not be blocked") + } +} diff --git a/web/controller/util.go b/web/controller/util.go index e1d53ba6..070d2c70 100644 --- a/web/controller/util.go +++ b/web/controller/util.go @@ -10,6 +10,7 @@ import ( "github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/web/entity" + "github.com/mhsanaei/3x-ui/v2/web/session" "github.com/gin-gonic/gin" ) @@ -121,6 +122,12 @@ func html(c *gin.Context, name string, title string, data gin.H) { data = gin.H{} } data["title"] = title + csrfToken, err := session.EnsureCSRFToken(c) + if err != nil { + logger.Warning("Unable to create CSRF token:", err) + } else { + data["csrf_token"] = csrfToken + } host := c.GetHeader("X-Forwarded-Host") if host == "" { host = c.GetHeader("X-Real-IP") diff --git a/web/controller/xui.go b/web/controller/xui.go index 51502900..afbbeb71 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -1,6 +1,8 @@ package controller import ( + "github.com/mhsanaei/3x-ui/v2/web/middleware" + "github.com/gin-gonic/gin" ) @@ -23,6 +25,7 @@ func NewXUIController(g *gin.RouterGroup) *XUIController { func (a *XUIController) initRouter(g *gin.RouterGroup) { g = g.Group("/panel") g.Use(a.checkLogin) + g.Use(middleware.CSRFMiddleware()) g.GET("/", a.index) g.GET("/inbounds", a.inbounds) diff --git a/web/html/common/page.html b/web/html/common/page.html index 47b2b654..13f5d64c 100644 --- a/web/html/common/page.html +++ b/web/html/common/page.html @@ -7,6 +7,7 @@ + {{ if .csrf_token }}{{ end }}