fix: improve request validation and session lifecycle (#12375)

This commit is contained in:
ssongliu
2026-03-31 10:58:05 +00:00
committed by GitHub
parent f787734feb
commit f573a0fa0e
11 changed files with 169 additions and 33 deletions
+1
View File
@@ -160,6 +160,7 @@ func (u *AuthService) LogOut(c *gin.Context) error {
sID, _ := c.Cookie(constant.SessionName)
if sID != "" {
c.SetCookie(constant.SessionName, sID, -1, "", "", httpsSetting.Value == constant.StatusEnable, true)
c.SetCookie(constant.CSRFTokenName, "", -1, "/", "", httpsSetting.Value == constant.StatusEnable, false)
err := global.SESSION.Delete(c)
if err != nil {
return err
+1
View File
@@ -308,6 +308,7 @@ func (u *SettingService) UpdateSSL(c *gin.Context, req dto.SSLUpdate) error {
secretDir := path.Join(global.CONF.Base.InstallDir, "1panel/secret")
if req.SSL == constant.StatusDisable {
c.SetCookie(constant.SessionName, "", -1, "/", "", false, true)
c.SetCookie(constant.CSRFTokenName, "", -1, "/", "", false, false)
if err := settingRepo.Update("SSL", constant.StatusDisable); err != nil {
return err
}
+2
View File
@@ -3,6 +3,8 @@ package constant
const (
AuthMethodSession = "session"
SessionName = "psession"
CSRFTokenName = "pcsrftoken"
CSRFHeaderName = "X-CSRF-Token"
PasswordExpiredName = "expired"
)
+1
View File
@@ -88,6 +88,7 @@ func Routers() *gin.Engine {
Router.Use(middleware.GlobalLoading())
Router.Use(middleware.PasswordExpired())
Router.Use(middleware.ApiAuth())
Router.Use(middleware.CSRFTokenGuard())
PrivateGroup := Router.Group("/api/v2/core")
PrivateGroup.Use(middleware.SetPasswordPublicKey())
+73 -27
View File
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"sync"
"sync/atomic"
"time"
@@ -18,6 +19,8 @@ type SessionUser struct {
}
type sessionItem struct {
CreatedAt time.Time
CSRFToken string
User SessionUser
ExpiredAt time.Time
}
@@ -29,6 +32,8 @@ type PSession struct {
lastFullCleanup time.Time
}
const maxSessionEntries = 64
func NewPSession(_ string) *PSession {
return &PSession{
sessions: make(map[string]sessionItem),
@@ -84,42 +89,82 @@ func (p *PSession) set(c *gin.Context, user SessionUser, secure bool, ttlSeconds
}
expiredAt := time.Now().Add(time.Duration(ttlSeconds) * time.Second)
createdAt := time.Now()
csrfToken := ""
p.mu.Lock()
if existing, ok := p.sessions[sessionID]; ok {
if !existing.CreatedAt.IsZero() {
createdAt = existing.CreatedAt
}
csrfToken = existing.CSRFToken
}
if csrfToken == "" {
csrfToken, err = generateSessionID()
if err != nil {
p.mu.Unlock()
return err
}
}
p.sessions[sessionID] = sessionItem{
CreatedAt: createdAt,
CSRFToken: csrfToken,
User: user,
ExpiredAt: expiredAt,
}
p.evictOverflowLocked(sessionID)
p.mu.Unlock()
p.cleanupExpiredOnWrite()
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(constant.SessionName, sessionID, ttlSeconds, "/", "", secure, true)
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(constant.CSRFTokenName, csrfToken, ttlSeconds, "/", "", secure, false)
return nil
}
func (p *PSession) evictOverflowLocked(currentSessionID string) {
if maxSessionEntries <= 0 || len(p.sessions) <= maxSessionEntries {
return
}
for len(p.sessions) > maxSessionEntries {
oldestID := ""
var oldestItem sessionItem
for sessionID, item := range p.sessions {
if sessionID == currentSessionID {
continue
}
if oldestID == "" || item.CreatedAt.Before(oldestItem.CreatedAt) {
oldestID = sessionID
oldestItem = item
}
}
if oldestID == "" {
return
}
delete(p.sessions, oldestID)
}
}
func (p *PSession) RefreshIfNeeded(c *gin.Context, user SessionUser, secure bool, ttlSeconds int) (bool, error) {
sessionID, err := c.Cookie(constant.SessionName)
if err != nil || sessionID == "" {
return false, p.Set(c, user, secure, ttlSeconds)
}
now := time.Now()
window := refreshWindow(ttlSeconds)
p.mu.RLock()
item, ok := p.sessions[sessionID]
p.mu.RUnlock()
if !ok {
return false, p.Set(c, user, secure, ttlSeconds)
}
if !item.ExpiredAt.IsZero() && now.After(item.ExpiredAt) {
if !item.ExpiredAt.IsZero() && time.Now().After(item.ExpiredAt) {
p.mu.Lock()
delete(p.sessions, sessionID)
p.mu.Unlock()
return false, errors.New("ErrSessionDataNotFound")
}
if item.ExpiredAt.Sub(now) > window {
return false, nil
}
return true, p.Set(c, user, secure, ttlSeconds)
}
@@ -133,6 +178,27 @@ func (p *PSession) Delete(c *gin.Context) error {
return nil
}
func (p *PSession) CheckCSRFToken(c *gin.Context, token string) bool {
sessionID, err := c.Cookie(constant.SessionName)
if err != nil || sessionID == "" || token == "" {
return false
}
p.mu.RLock()
item, ok := p.sessions[sessionID]
p.mu.RUnlock()
if !ok {
return false
}
if !item.ExpiredAt.IsZero() && time.Now().After(item.ExpiredAt) {
p.mu.Lock()
delete(p.sessions, sessionID)
p.mu.Unlock()
return false
}
return item.CSRFToken == token
}
func (p *PSession) Clean() error {
p.mu.Lock()
p.sessions = make(map[string]sessionItem)
@@ -149,26 +215,6 @@ func generateSessionID() (string, error) {
return hex.EncodeToString(buf), nil
}
func refreshWindow(ttlSeconds int) time.Duration {
if ttlSeconds <= 0 {
return 0
}
windowSeconds := ttlSeconds / 10
if windowSeconds < 60 {
windowSeconds = 60
}
if windowSeconds > 300 {
windowSeconds = 300
}
if windowSeconds >= ttlSeconds {
windowSeconds = ttlSeconds - 1
}
if windowSeconds <= 0 {
windowSeconds = 1
}
return time.Duration(windowSeconds) * time.Second
}
func (p *PSession) cleanupExpiredOnWrite() {
const (
sampleSize = 32
+57
View File
@@ -0,0 +1,57 @@
package middleware
import (
"net/http"
"strings"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/gin-gonic/gin"
)
func CSRFTokenGuard() gin.HandlerFunc {
return func(c *gin.Context) {
if !requiresCSRFTokenCheck(c) {
c.Next()
return
}
token := strings.TrimSpace(c.GetHeader(constant.CSRFHeaderName))
if !global.SESSION.CheckCSRFToken(c, token) {
c.AbortWithStatusJSON(http.StatusForbidden, dto.Response{
Code: http.StatusForbidden,
Message: "invalid request token",
Data: nil,
})
return
}
c.Next()
}
}
func requiresCSRFTokenCheck(c *gin.Context) bool {
unsafeMethod := c.Request.Method != http.MethodGet &&
c.Request.Method != http.MethodHead &&
c.Request.Method != http.MethodOptions &&
c.Request.Method != http.MethodTrace
if !unsafeMethod {
return false
}
if !strings.HasPrefix(c.Request.URL.Path, "/api/v2/") {
return false
}
switch c.Request.URL.Path {
case "/api/v2/core/auth/login",
"/api/v2/core/auth/mfalogin",
"/api/v2/core/auth/passkey/begin",
"/api/v2/core/auth/passkey/finish":
return false
}
if c.GetBool("API_AUTH") {
return false
}
sessionID, err := c.Cookie(constant.SessionName)
return err == nil && sessionID != ""
}
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net"
"net/http"
@@ -13,6 +12,8 @@ import (
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/i18n"
)
@@ -62,13 +63,21 @@ func NewLocalClient(reqUrl, reqMethod string, body io.Reader, ctx *gin.Context)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("do request failed, err: %v", resp.Status)
}
bodyByte, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read resp body from request failed, err: %v", err)
}
if resp.StatusCode != http.StatusOK {
var respJSON dto.Response
if err := json.Unmarshal(bodyByte, &respJSON); err == nil && respJSON.Message != "" {
return nil, fmt.Errorf("do request failed, status=%v, message=%s", resp.Status, respJSON.Message)
}
if msg := strings.TrimSpace(string(bodyByte)); msg != "" {
return nil, fmt.Errorf("do request failed, status=%v, body=%s", resp.Status, msg)
}
return nil, fmt.Errorf("do request failed, err: %v", resp.Status)
}
var respJson dto.Response
if err := json.Unmarshal(bodyByte, &respJson); err != nil {
return nil, fmt.Errorf("json umarshal resp data failed, err: %v", err)
+18 -1
View File
@@ -8,6 +8,7 @@ import { MsgError } from '@/utils/message';
import { Base64 } from 'js-base64';
import i18n from '@/lang';
import { changeToLocal } from '@/utils/node';
import { getCookie } from '@/utils/util';
const globalStore = GlobalStore();
@@ -42,6 +43,15 @@ class RequestHttp {
let entrance = Base64.encode(globalStore.entrance);
config.headers.EntranceCode = entrance;
}
const method = (config.method || 'get').toUpperCase();
const requiresToken = !['GET', 'HEAD', 'OPTIONS', 'TRACE'].includes(method);
if (requiresToken) {
const csrfToken = getCookie('pcsrftoken');
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken;
globalStore.setCsrfToken(csrfToken);
}
}
return {
...config,
} as InternalAxiosRequestConfig<any>;
@@ -107,6 +117,13 @@ class RequestHttp {
case 313:
router.push({ name: 'Expired' });
return;
case 403:
if (response.data && response.data['message']) {
MsgError(response.data['message']);
} else {
MsgError(i18n.global.t('commons.res.forbidden'));
}
return Promise.reject(error);
case 500:
case 502:
case 524:
@@ -117,7 +134,7 @@ class RequestHttp {
);
return Promise.reject(error);
default:
return;
return Promise.reject(error);
}
}
if (!window.navigator.onLine) router.replace({ path: '/500' });
+1
View File
@@ -30,6 +30,7 @@ export interface GlobalState {
isLoading: boolean;
loadingText: string;
isLogin: boolean;
csrfToken: string;
entrance: string;
language: string; // zh | en | tw
themeConfig: ThemeConfigProp;
+1
View File
@@ -13,6 +13,7 @@ const GlobalStore = defineStore({
isLoading: false,
loadingText: '',
isLogin: false,
csrfToken: '',
entrance: '',
language: i18n.global.locale.value,
themeConfig: {
+1 -1
View File
@@ -790,7 +790,7 @@ export function getRuntimeLabel(type: string) {
return '';
}
function getCookie(name: string) {
export function getCookie(name: string) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();