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
+47
View File
@@ -0,0 +1,47 @@
package middleware
import (
"net/http"
"github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin"
)
// SecurityHeadersMiddleware adds browser hardening headers to panel responses.
func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "no-referrer")
c.Header("Content-Security-Policy", "frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
if directHTTPS {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
c.Next()
}
}
// CSRFMiddleware rejects unsafe requests that do not include the session CSRF token.
func CSRFMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if isSafeMethod(c.Request.Method) {
c.Next()
return
}
if !session.ValidateCSRFToken(c) {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.Next()
}
}
func isSafeMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
return true
default:
return false
}
}
+121
View File
@@ -0,0 +1,121 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
func TestCSRFMiddlewareAllowsSafeMethods(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(CSRFMiddleware())
router.GET("/safe", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/safe", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestCSRFMiddlewareRejectsMissingTokenAndAcceptsValidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
store := cookie.NewStore([]byte("01234567890123456789012345678901"))
router.Use(sessions.Sessions("3x-ui", store))
router.GET("/token", func(c *gin.Context) {
token, err := session.EnsureCSRFToken(c)
if err != nil {
t.Fatal(err)
}
c.String(http.StatusOK, token)
})
router.POST("/submit", CSRFMiddleware(), func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
tokenRec := httptest.NewRecorder()
tokenReq := httptest.NewRequest(http.MethodGet, "/token", nil)
router.ServeHTTP(tokenRec, tokenReq)
if tokenRec.Code != http.StatusOK {
t.Fatalf("token status = %d, want %d", tokenRec.Code, http.StatusOK)
}
cookies := tokenRec.Result().Cookies()
token := tokenRec.Body.String()
missingRec := httptest.NewRecorder()
missingReq := httptest.NewRequest(http.MethodPost, "/submit", nil)
for _, cookie := range cookies {
missingReq.AddCookie(cookie)
}
router.ServeHTTP(missingRec, missingReq)
if missingRec.Code != http.StatusForbidden {
t.Fatalf("missing token status = %d, want %d", missingRec.Code, http.StatusForbidden)
}
validRec := httptest.NewRecorder()
validReq := httptest.NewRequest(http.MethodPost, "/submit", nil)
for _, cookie := range cookies {
validReq.AddCookie(cookie)
}
validReq.Header.Set(session.CSRFHeaderName, token)
router.ServeHTTP(validRec, validReq)
if validRec.Code != http.StatusOK {
t.Fatalf("valid token status = %d, want %d", validRec.Code, http.StatusOK)
}
}
func TestSecurityHeadersMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(SecurityHeadersMiddleware(true))
router.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
router.ServeHTTP(rec, req)
headers := rec.Result().Header
if got := headers.Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q", got)
}
if got := headers.Get("X-Frame-Options"); got != "DENY" {
t.Fatalf("X-Frame-Options = %q", got)
}
if got := headers.Get("Referrer-Policy"); got != "no-referrer" {
t.Fatalf("Referrer-Policy = %q", got)
}
if got := headers.Get("Strict-Transport-Security"); got == "" {
t.Fatal("Strict-Transport-Security should be set for direct HTTPS")
}
}
func TestSecurityHeadersMiddlewareSkipsHSTSWithoutDirectHTTPS(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(SecurityHeadersMiddleware(false))
router.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
router.ServeHTTP(rec, req)
if got := rec.Result().Header.Get("Strict-Transport-Security"); got != "" {
t.Fatalf("Strict-Transport-Security = %q, want empty", got)
}
}