mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 00:00:50 +00:00
fix: improve request validation and session lifecycle (#12375)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package constant
|
||||
const (
|
||||
AuthMethodSession = "session"
|
||||
SessionName = "psession"
|
||||
CSRFTokenName = "pcsrftoken"
|
||||
CSRFHeaderName = "X-CSRF-Token"
|
||||
|
||||
PasswordExpiredName = "expired"
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface GlobalState {
|
||||
isLoading: boolean;
|
||||
loadingText: string;
|
||||
isLogin: boolean;
|
||||
csrfToken: string;
|
||||
entrance: string;
|
||||
language: string; // zh | en | tw
|
||||
themeConfig: ThemeConfigProp;
|
||||
|
||||
@@ -13,6 +13,7 @@ const GlobalStore = defineStore({
|
||||
isLoading: false,
|
||||
loadingText: '',
|
||||
isLogin: false,
|
||||
csrfToken: '',
|
||||
entrance: '',
|
||||
language: i18n.global.locale.value,
|
||||
themeConfig: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user