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
This commit is contained in:
Farhad H. P. Shirvan
2026-05-07 23:36:11 +02:00
committed by GitHub
parent a1b2382877
commit 10ebc6cbdc
28 changed files with 525 additions and 41 deletions

View File

@@ -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")

View File

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

View File

@@ -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:]
}

View File

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

View File

@@ -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")

View File

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