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,12 @@ axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
axios.interceptors.request.use( axios.interceptors.request.use(
(config) => { (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) { if (config.data instanceof FormData) {
config.headers['Content-Type'] = 'multipart/form-data'; config.headers['Content-Type'] = 'multipart/form-data';
} else { } else {

View File

@@ -3,6 +3,7 @@ package controller
import ( import (
"net/http" "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/service"
"github.com/mhsanaei/3x-ui/v2/web/session" "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 // Main API group
api := g.Group("/panel/api") api := g.Group("/panel/api")
api.Use(a.checkAPIAuth) api.Use(a.checkAPIAuth)
api.Use(middleware.CSRFMiddleware())
// Inbounds API // Inbounds API
inbounds := api.Group("/inbounds") inbounds := api.Group("/inbounds")

View File

@@ -1,12 +1,12 @@
package controller package controller
import ( import (
"fmt"
"net/http" "net/http"
"text/template" "text/template"
"time" "time"
"github.com/mhsanaei/3x-ui/v2/logger" "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/service"
"github.com/mhsanaei/3x-ui/v2/web/session" "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("/", a.index)
g.GET("/logout", a.logout) g.GET("/logout", a.logout)
g.POST("/login", a.login) g.POST("/login", middleware.CSRFMiddleware(), a.login)
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable) g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
} }
// index handles the root route, redirecting logged-in users to the panel or showing the login page. // 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 return
} }
user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode) remoteIP := getRemoteIp(c)
timeStr := time.Now().Format("2006-01-02 15:04:05")
safeUser := template.HTMLEscapeString(form.Username) safeUser := template.HTMLEscapeString(form.Username)
safePass := template.HTMLEscapeString(form.Password) timeStr := time.Now().Format("2006-01-02 15:04:05")
if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
if user == nil { reason := "too many failed attempts"
logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c)) 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{
notifyPass := safePass Username: safeUser,
IP: remoteIP,
if checkErr != nil && checkErr.Error() == "invalid 2fa code" { Time: timeStr,
translatedError := a.tgbot.I18nBot("tgbot.messages.2faFailed") Status: service.LoginFail,
notifyPass = fmt.Sprintf("*** (%s)", translatedError) Reason: reason,
} })
a.tgbot.UserLoginNotify(safeUser, notifyPass, getRemoteIp(c), timeStr, 0)
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword")) pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
return return
} }
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, getRemoteIp(c)) user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
a.tgbot.UserLoginNotify(safeUser, ``, getRemoteIp(c), timeStr, 1)
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 { if err := session.SetLoginUser(c, user); err != nil {
logger.Warning("Unable to save session:", err) 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) 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. // logout handles user logout by clearing the session and redirecting to the login page.
func (a *IndexController) logout(c *gin.Context) { func (a *IndexController) logout(c *gin.Context) {
user := session.GetLoginUser(c) 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/config"
"github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/entity" "github.com/mhsanaei/3x-ui/v2/web/entity"
"github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin" "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 = gin.H{}
} }
data["title"] = title 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") host := c.GetHeader("X-Forwarded-Host")
if host == "" { if host == "" {
host = c.GetHeader("X-Real-IP") host = c.GetHeader("X-Real-IP")

View File

@@ -1,6 +1,8 @@
package controller package controller
import ( import (
"github.com/mhsanaei/3x-ui/v2/web/middleware"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -23,6 +25,7 @@ func NewXUIController(g *gin.RouterGroup) *XUIController {
func (a *XUIController) initRouter(g *gin.RouterGroup) { func (a *XUIController) initRouter(g *gin.RouterGroup) {
g = g.Group("/panel") g = g.Group("/panel")
g.Use(a.checkLogin) g.Use(a.checkLogin)
g.Use(middleware.CSRFMiddleware())
g.GET("/", a.index) g.GET("/", a.index)
g.GET("/inbounds", a.inbounds) g.GET("/inbounds", a.inbounds)

View File

@@ -7,6 +7,7 @@
<meta name="renderer" content="webkit"> <meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex,nofollow"> <meta name="robots" content="noindex,nofollow">
{{ if .csrf_token }}<meta name="csrf-token" content="{{ .csrf_token }}">{{ end }}
<link rel="stylesheet" href="{{ .base_path }}assets/ant-design-vue/antd.min.css"> <link rel="stylesheet" href="{{ .base_path }}assets/ant-design-vue/antd.min.css">
<link rel="stylesheet" href="{{ .base_path }}assets/css/custom.min.css?{{ .cur_ver }}"> <link rel="stylesheet" href="{{ .base_path }}assets/css/custom.min.css?{{ .cur_ver }}">
<style> <style>

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
}
}

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

View File

@@ -104,6 +104,16 @@ const (
EmptyTelegramUserID = int64(0) // Default value for empty Telegram user ID EmptyTelegramUserID = int64(0) // Default value for empty Telegram user ID
) )
// LoginAttempt contains safe metadata for panel login notifications.
// It intentionally does not include attempted passwords.
type LoginAttempt struct {
Username string
IP string
Time string
Status LoginStatus
Reason string
}
// Tgbot provides business logic for Telegram bot integration. // Tgbot provides business logic for Telegram bot integration.
// It handles bot commands, user interactions, and status reporting via Telegram. // It handles bot commands, user interactions, and status reporting via Telegram.
type Tgbot struct { type Tgbot struct {
@@ -2769,12 +2779,12 @@ func (t *Tgbot) prepareServerUsageInfo() string {
} }
// UserLoginNotify sends a notification about user login attempts to admins. // UserLoginNotify sends a notification about user login attempts to admins.
func (t *Tgbot) UserLoginNotify(username string, password string, ip string, time string, status LoginStatus) { func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
if !t.IsRunning() { if !t.IsRunning() {
return return
} }
if username == "" || ip == "" || time == "" { if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" {
logger.Warning("UserLoginNotify failed, invalid info!") logger.Warning("UserLoginNotify failed, invalid info!")
return return
} }
@@ -2785,18 +2795,20 @@ func (t *Tgbot) UserLoginNotify(username string, password string, ip string, tim
} }
msg := "" msg := ""
switch status { switch attempt.Status {
case LoginSuccess: case LoginSuccess:
msg += t.I18nBot("tgbot.messages.loginSuccess") msg += t.I18nBot("tgbot.messages.loginSuccess")
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
case LoginFail: case LoginFail:
msg += t.I18nBot("tgbot.messages.loginFailed") msg += t.I18nBot("tgbot.messages.loginFailed")
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
msg += t.I18nBot("tgbot.messages.password", "Password=="+password) if attempt.Reason != "" {
msg += t.I18nBot("tgbot.messages.reason", "Reason=="+attempt.Reason)
}
} }
msg += t.I18nBot("tgbot.messages.username", "Username=="+username) msg += t.I18nBot("tgbot.messages.username", "Username=="+attempt.Username)
msg += t.I18nBot("tgbot.messages.ip", "IP=="+ip) msg += t.I18nBot("tgbot.messages.ip", "IP=="+attempt.IP)
msg += t.I18nBot("tgbot.messages.time", "Time=="+time) msg += t.I18nBot("tgbot.messages.time", "Time=="+attempt.Time)
t.SendMsgToTgbotAdmins(msg) t.SendMsgToTgbotAdmins(msg)
} }

13
web/service/tgbot_test.go Normal file
View File

@@ -0,0 +1,13 @@
package service
import (
"reflect"
"testing"
)
func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
typ := reflect.TypeOf(LoginAttempt{})
if _, ok := typ.FieldByName("Password"); ok {
t.Fatal("LoginAttempt must not carry attempted passwords")
}
}

55
web/session/csrf.go Normal file
View File

@@ -0,0 +1,55 @@
package session
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"io"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const csrfTokenKey = "CSRF_TOKEN"
// CSRFHeaderName is the request header used by browser clients for unsafe methods.
const CSRFHeaderName = "X-CSRF-Token"
// EnsureCSRFToken returns the current session CSRF token or creates one.
func EnsureCSRFToken(c *gin.Context) (string, error) {
s := sessions.Default(c)
if token, ok := s.Get(csrfTokenKey).(string); ok && token != "" {
return token, nil
}
token, err := newCSRFToken()
if err != nil {
return "", err
}
s.Set(csrfTokenKey, token)
return token, s.Save()
}
// ValidateCSRFToken checks the submitted CSRF token against the session token.
func ValidateCSRFToken(c *gin.Context) bool {
s := sessions.Default(c)
expected, ok := s.Get(csrfTokenKey).(string)
if !ok || expected == "" {
return false
}
actual := c.GetHeader(CSRFHeaderName)
if actual == "" {
actual = c.PostForm("_csrf")
}
if len(actual) != len(expected) {
return false
}
return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
}
func newCSRFToken() (string, error) {
buf := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}

View File

@@ -73,6 +73,7 @@ func ClearSession(c *gin.Context) error {
Path: cookiePath, Path: cookiePath,
MaxAge: -1, MaxAge: -1,
HttpOnly: true, HttpOnly: true,
Secure: c.Request.TLS != nil,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
return s.Save() return s.Save()

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 الترافيك: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 الترافيك: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " الحالة: {{ .State }}\r\n" "xrayStatus" = " الحالة: {{ .State }}\r\n"
"username" = "👤 اسم المستخدم: {{ .Username }}\r\n" "username" = "👤 اسم المستخدم: {{ .Username }}\r\n"
"password" = "👤 الباسورد: {{ .Password }}\r\n" "reason" = "❗️ السبب: {{ .Reason }}\r\n"
"time" = "⏰ الوقت: {{ .Time }}\r\n" "time" = "⏰ الوقت: {{ .Time }}\r\n"
"inbound" = "📍 الإدخال: {{ .Remark }}\r\n" "inbound" = "📍 الإدخال: {{ .Remark }}\r\n"
"port" = "🔌 البورت: {{ .Port }}\r\n" "port" = "🔌 البورت: {{ .Port }}\r\n"

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 Traffic: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 Traffic: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " Status: {{ .State }}\r\n" "xrayStatus" = " Status: {{ .State }}\r\n"
"username" = "👤 Username: {{ .Username }}\r\n" "username" = "👤 Username: {{ .Username }}\r\n"
"password" = "👤 Password: {{ .Password }}\r\n" "reason" = "❗️ Reason: {{ .Reason }}\r\n"
"time" = "⏰ Time: {{ .Time }}\r\n" "time" = "⏰ Time: {{ .Time }}\r\n"
"inbound" = "📍 Inbound: {{ .Remark }}\r\n" "inbound" = "📍 Inbound: {{ .Remark }}\r\n"
"port" = "🔌 Port: {{ .Port }}\r\n" "port" = "🔌 Port: {{ .Port }}\r\n"

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 Tráfico: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 Tráfico: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " Estado de Xray: {{ .State }}\r\n" "xrayStatus" = " Estado de Xray: {{ .State }}\r\n"
"username" = "👤 Nombre de usuario: {{ .Username }}\r\n" "username" = "👤 Nombre de usuario: {{ .Username }}\r\n"
"password" = "👤 Contraseña: {{ .Password }}\r\n" "reason" = "❗️ Motivo: {{ .Reason }}\r\n"
"time" = "⏰ Hora: {{ .Time }}\r\n" "time" = "⏰ Hora: {{ .Time }}\r\n"
"inbound" = "📍 Inbound: {{ .Remark }}\r\n" "inbound" = "📍 Inbound: {{ .Remark }}\r\n"
"port" = "🔌 Puerto: {{ .Port }}\r\n" "port" = "🔌 Puerto: {{ .Port }}\r\n"

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 ترافیک: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 ترافیک: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " وضعیت‌ایکس‌ری: {{ .State }}\r\n" "xrayStatus" = " وضعیت‌ایکس‌ری: {{ .State }}\r\n"
"username" = "👤 نام‌کاربری: {{ .Username }}\r\n" "username" = "👤 نام‌کاربری: {{ .Username }}\r\n"
"password" = "👤 رمز عبور: {{ .Password }}\r\n" "reason" = "❗️ دلیل: {{ .Reason }}\r\n"
"time" = "⏰ زمان: {{ .Time }}\r\n" "time" = "⏰ زمان: {{ .Time }}\r\n"
"inbound" = "📍 نام‌ورودی: {{ .Remark }}\r\n" "inbound" = "📍 نام‌ورودی: {{ .Remark }}\r\n"
"port" = "🔌 پورت: {{ .Port }}\r\n" "port" = "🔌 پورت: {{ .Port }}\r\n"

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 Lalu Lintas: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 Lalu Lintas: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " Status: {{ .State }}\r\n" "xrayStatus" = " Status: {{ .State }}\r\n"
"username" = "👤 Nama Pengguna: {{ .Username }}\r\n" "username" = "👤 Nama Pengguna: {{ .Username }}\r\n"
"password" = "👤 Kata Sandi: {{ .Password }}\r\n" "reason" = "❗️ Alasan: {{ .Reason }}\r\n"
"time" = "⏰ Waktu: {{ .Time }}\r\n" "time" = "⏰ Waktu: {{ .Time }}\r\n"
"inbound" = "📍 Inbound: {{ .Remark }}\r\n" "inbound" = "📍 Inbound: {{ .Remark }}\r\n"
"port" = "🔌 Port: {{ .Port }}\r\n" "port" = "🔌 Port: {{ .Port }}\r\n"

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 トラフィック:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 トラフィック:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " Xrayステータス{{ .State }}\r\n" "xrayStatus" = " Xrayステータス{{ .State }}\r\n"
"username" = "👤 ユーザー名:{{ .Username }}\r\n" "username" = "👤 ユーザー名:{{ .Username }}\r\n"
"password" = "👤 パスワード: {{ .Password }}\r\n" "reason" = "❗️ 理由:{{ .Reason }}\r\n"
"time" = "⏰ 時間:{{ .Time }}\r\n" "time" = "⏰ 時間:{{ .Time }}\r\n"
"inbound" = "📍 インバウンド:{{ .Remark }}\r\n" "inbound" = "📍 インバウンド:{{ .Remark }}\r\n"
"port" = "🔌 ポート:{{ .Port }}\r\n" "port" = "🔌 ポート:{{ .Port }}\r\n"

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 Tráfego: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 Tráfego: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " Status: {{ .State }}\r\n" "xrayStatus" = " Status: {{ .State }}\r\n"
"username" = "👤 Nome de usuário: {{ .Username }}\r\n" "username" = "👤 Nome de usuário: {{ .Username }}\r\n"
"password" = "👤 Senha: {{ .Password }}\r\n" "reason" = "❗️ Motivo: {{ .Reason }}\r\n"
"time" = "⏰ Hora: {{ .Time }}\r\n" "time" = "⏰ Hora: {{ .Time }}\r\n"
"inbound" = "📍 Inbound: {{ .Remark }}\r\n" "inbound" = "📍 Inbound: {{ .Remark }}\r\n"
"port" = "🔌 Porta: {{ .Port }}\r\n" "port" = "🔌 Porta: {{ .Port }}\r\n"

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 Трафик: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 Трафик: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " Состояние Xray: {{ .State }}\r\n" "xrayStatus" = " Состояние Xray: {{ .State }}\r\n"
"username" = "👤 Имя пользователя: {{ .Username }}\r\n" "username" = "👤 Имя пользователя: {{ .Username }}\r\n"
"password" = "👤 Пароль: {{ .Password }}\r\n" "reason" = "❗️ Причина: {{ .Reason }}\r\n"
"time" = "⏰ Время: {{ .Time }}\r\n" "time" = "⏰ Время: {{ .Time }}\r\n"
"inbound" = "📍 Входящее подключение: {{ .Remark }}\r\n" "inbound" = "📍 Входящее подключение: {{ .Remark }}\r\n"
"port" = "🔌 Порт: {{ .Port }}\r\n" "port" = "🔌 Порт: {{ .Port }}\r\n"

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 Trafik: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 Trafik: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " Durum: {{ .State }}\r\n" "xrayStatus" = " Durum: {{ .State }}\r\n"
"username" = "👤 Kullanıcı Adı: {{ .Username }}\r\n" "username" = "👤 Kullanıcı Adı: {{ .Username }}\r\n"
"password" = "👤 Şifre: {{ .Password }}\r\n" "reason" = "❗️ Sebep: {{ .Reason }}\r\n"
"time" = "⏰ Zaman: {{ .Time }}\r\n" "time" = "⏰ Zaman: {{ .Time }}\r\n"
"inbound" = "📍 Gelen: {{ .Remark }}\r\n" "inbound" = "📍 Gelen: {{ .Remark }}\r\n"
"port" = "🔌 Port: {{ .Port }}\r\n" "port" = "🔌 Port: {{ .Port }}\r\n"

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 Трафік: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 Трафік: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " Статус: {{ .State }}\r\n" "xrayStatus" = " Статус: {{ .State }}\r\n"
"username" = "👤 Ім'я користувача: {{ .Username }}\r\n" "username" = "👤 Ім'я користувача: {{ .Username }}\r\n"
"password" = "👤 Пароль: {{ .Password }}\r\n" "reason" = "❗️ Причина: {{ .Reason }}\r\n"
"time" = "⏰ Час: {{ .Time }}\r\n" "time" = "⏰ Час: {{ .Time }}\r\n"
"inbound" = "📍 Inbound: {{ .Remark }}\r\n" "inbound" = "📍 Inbound: {{ .Remark }}\r\n"
"port" = "🔌 Порт: {{ .Port }}\r\n" "port" = "🔌 Порт: {{ .Port }}\r\n"

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 Lưu lượng: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 Lưu lượng: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " Trạng thái Xray: {{ .State }}\r\n" "xrayStatus" = " Trạng thái Xray: {{ .State }}\r\n"
"username" = "👤 Tên người dùng: {{ .Username }}\r\n" "username" = "👤 Tên người dùng: {{ .Username }}\r\n"
"password" = "👤 Mật khẩu: {{ .Password }}\r\n" "reason" = "❗️ Lý do: {{ .Reason }}\r\n"
"time" = "⏰ Thời gian: {{ .Time }}\r\n" "time" = "⏰ Thời gian: {{ .Time }}\r\n"
"inbound" = "📍 Inbound: {{ .Remark }}\r\n" "inbound" = "📍 Inbound: {{ .Remark }}\r\n"
"port" = "🔌 Cổng: {{ .Port }}\r\n" "port" = "🔌 Cổng: {{ .Port }}\r\n"

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 流量:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 流量:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " Xray 状态:{{ .State }}\r\n" "xrayStatus" = " Xray 状态:{{ .State }}\r\n"
"username" = "👤 用户名:{{ .Username }}\r\n" "username" = "👤 用户名:{{ .Username }}\r\n"
"password" = "👤 密码: {{ .Password }}\r\n" "reason" = "❗️ 原因:{{ .Reason }}\r\n"
"time" = "⏰ 时间:{{ .Time }}\r\n" "time" = "⏰ 时间:{{ .Time }}\r\n"
"inbound" = "📍 入站:{{ .Remark }}\r\n" "inbound" = "📍 入站:{{ .Remark }}\r\n"
"port" = "🔌 端口:{{ .Port }}\r\n" "port" = "🔌 端口:{{ .Port }}\r\n"

View File

@@ -765,7 +765,7 @@
"traffic" = "🚦 流量:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n" "traffic" = "🚦 流量:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
"xrayStatus" = " Xray 狀態:{{ .State }}\r\n" "xrayStatus" = " Xray 狀態:{{ .State }}\r\n"
"username" = "👤 使用者名稱:{{ .Username }}\r\n" "username" = "👤 使用者名稱:{{ .Username }}\r\n"
"password" = "👤 密碼: {{ .Password }}\r\n" "reason" = "❗️ 原因:{{ .Reason }}\r\n"
"time" = "⏰ 時間:{{ .Time }}\r\n" "time" = "⏰ 時間:{{ .Time }}\r\n"
"inbound" = "📍 入站:{{ .Remark }}\r\n" "inbound" = "📍 入站:{{ .Remark }}\r\n"
"port" = "🔌 埠:{{ .Port }}\r\n" "port" = "🔌 埠:{{ .Port }}\r\n"

View File

@@ -170,6 +170,16 @@ func (s *Server) getHtmlTemplate(funcMap template.FuncMap) (*template.Template,
return t, nil return t, nil
} }
func (s *Server) isDirectHTTPSConfigured() bool {
certFile, certErr := s.settingService.GetCertFile()
keyFile, keyErr := s.settingService.GetKeyFile()
if certErr != nil || keyErr != nil || certFile == "" || keyFile == "" {
return false
}
_, err := tls.LoadX509KeyPair(certFile, keyFile)
return err == nil
}
// initRouter initializes Gin, registers middleware, templates, static // initRouter initializes Gin, registers middleware, templates, static
// assets, controllers and returns the configured engine. // assets, controllers and returns the configured engine.
func (s *Server) initRouter() (*gin.Engine, error) { func (s *Server) initRouter() (*gin.Engine, error) {
@@ -182,6 +192,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
} }
engine := gin.Default() engine := gin.Default()
directHTTPS := s.isDirectHTTPSConfigured()
engine.Use(middleware.SecurityHeadersMiddleware(directHTTPS))
webDomain, err := s.settingService.GetWebDomain() webDomain, err := s.settingService.GetWebDomain()
if err != nil { if err != nil {
@@ -209,6 +221,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
sessionOptions := sessions.Options{ sessionOptions := sessions.Options{
Path: basePath, Path: basePath,
HttpOnly: true, HttpOnly: true,
Secure: directHTTPS,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
} }
if sessionMaxAge, err := s.settingService.GetSessionMaxAge(); err == nil && sessionMaxAge > 0 { if sessionMaxAge, err := s.settingService.GetSessionMaxAge(); err == nil && sessionMaxAge > 0 {