From 10ebc6cbdce4d97bf652c1bac435b296cb221cb5 Mon Sep 17 00:00:00 2001
From: "Farhad H. P. Shirvan" <9374298+farhadh@users.noreply.github.com>
Date: Thu, 7 May 2026 23:36:11 +0200
Subject: [PATCH] Implement CSRF protection and security hardening across the
application (#4179)
* Implement CSRF protection and security hardening across the application
- Added CSRF token handling in axios requests and HTML templates.
- Introduced CSRF middleware to validate tokens for unsafe HTTP methods.
- Implemented login limiter to prevent brute-force attacks.
- Enhanced security headers in middleware for improved response security.
- Updated login notification to include safe metadata without passwords.
- Added tests for CSRF middleware and login limiter functionality.
* fix
---
web/assets/js/axios-init.js | 6 ++
web/controller/api.go | 2 +
web/controller/index.go | 70 +++++++++++-----
web/controller/login_limiter.go | 99 ++++++++++++++++++++++
web/controller/login_limiter_test.go | 74 ++++++++++++++++
web/controller/util.go | 7 ++
web/controller/xui.go | 3 +
web/html/common/page.html | 3 +-
web/middleware/security.go | 47 +++++++++++
web/middleware/security_test.go | 121 +++++++++++++++++++++++++++
web/service/tgbot.go | 26 ++++--
web/service/tgbot_test.go | 13 +++
web/session/csrf.go | 55 ++++++++++++
web/session/session.go | 1 +
web/translation/translate.ar_EG.toml | 2 +-
web/translation/translate.en_US.toml | 2 +-
web/translation/translate.es_ES.toml | 2 +-
web/translation/translate.fa_IR.toml | 2 +-
web/translation/translate.id_ID.toml | 2 +-
web/translation/translate.ja_JP.toml | 2 +-
web/translation/translate.pt_BR.toml | 2 +-
web/translation/translate.ru_RU.toml | 2 +-
web/translation/translate.tr_TR.toml | 2 +-
web/translation/translate.uk_UA.toml | 2 +-
web/translation/translate.vi_VN.toml | 2 +-
web/translation/translate.zh_CN.toml | 2 +-
web/translation/translate.zh_TW.toml | 2 +-
web/web.go | 13 +++
28 files changed, 525 insertions(+), 41 deletions(-)
create mode 100644 web/controller/login_limiter.go
create mode 100644 web/controller/login_limiter_test.go
create mode 100644 web/middleware/security.go
create mode 100644 web/middleware/security_test.go
create mode 100644 web/service/tgbot_test.go
create mode 100644 web/session/csrf.go
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 }}