mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-08 14:36:13 +00:00
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:
committed by
GitHub
parent
a1b2382877
commit
10ebc6cbdc
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
99
web/controller/login_limiter.go
Normal file
99
web/controller/login_limiter.go
Normal 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:]
|
||||
}
|
||||
74
web/controller/login_limiter_test.go
Normal file
74
web/controller/login_limiter_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user