diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js index 98e2d789..a9cafc78 100644 --- a/frontend/src/api/axios-init.js +++ b/frontend/src/api/axios-init.js @@ -1,19 +1,68 @@ import axios from 'axios'; import qs from 'qs'; +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']); +const CSRF_TOKEN_PATH = '/panel/csrf-token'; + +// Cached session CSRF token. The legacy panel injects it via a +// tag rendered by Go; the new SPA pages +// fetch it once from /panel/csrf-token instead. Module-level so +// every axios POST sees the latest value. +let csrfToken = null; +let csrfFetchPromise = null; + +function readMetaToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null; +} + +// Fetch the token via a bare fetch() (not axios) so the call doesn't +// recurse through this same interceptor. +async function fetchCsrfToken() { + try { + const res = await fetch(CSRF_TOKEN_PATH, { + method: 'GET', + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + if (!res.ok) return null; + const json = await res.json(); + return json?.success && typeof json.obj === 'string' ? json.obj : null; + } catch (_e) { + return null; + } +} + +async function ensureCsrfToken() { + if (csrfToken) return csrfToken; + const meta = readMetaToken(); + if (meta) { + csrfToken = meta; + return csrfToken; + } + if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken(); + const fetched = await csrfFetchPromise; + csrfFetchPromise = null; + if (fetched) csrfToken = fetched; + return csrfToken; +} + // Apply the panel's axios defaults + interceptors. Call once at app // startup before any HTTP call goes out. export function setupAxios() { axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; + // Seed the cache from the meta tag if a server-rendered page injected + // one — saves a round trip on legacy templates that still embed it. + csrfToken = readMetaToken(); + axios.interceptors.request.use( - (config) => { + async (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 (!SAFE_METHODS.has(method)) { + const token = await ensureCsrfToken(); + if (token) config.headers['X-CSRF-Token'] = token; } if (config.data instanceof FormData) { config.headers['Content-Type'] = 'multipart/form-data'; @@ -27,9 +76,26 @@ export function setupAxios() { axios.interceptors.response.use( (response) => response, - (error) => { - if (error.response && error.response.status === 401) { - return window.location.reload(); + async (error) => { + const status = error.response?.status; + if (status === 401) { + window.location.reload(); + return Promise.reject(error); + } + // 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once. + const cfg = error.config; + if (status === 403 && cfg && !cfg.__csrfRetried) { + csrfToken = null; + cfg.__csrfRetried = true; + const token = await ensureCsrfToken(); + if (token) { + cfg.headers = cfg.headers || {}; + cfg.headers['X-CSRF-Token'] = token; + // axios re-stringifies on retry, so unwind our qs.stringify before + // letting the same request flow through the interceptor again. + if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data); + return axios(cfg); + } } return Promise.reject(error); }, diff --git a/web/controller/xui.go b/web/controller/xui.go index afbbeb71..7c2648c2 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -1,7 +1,11 @@ package controller import ( + "net/http" + + "github.com/mhsanaei/3x-ui/v2/web/entity" "github.com/mhsanaei/3x-ui/v2/web/middleware" + "github.com/mhsanaei/3x-ui/v2/web/session" "github.com/gin-gonic/gin" ) @@ -32,6 +36,11 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { g.GET("/settings", a.settings) g.GET("/xray", a.xraySettings) + // SPA pages built by Vite don't have a server-rendered , + // so they fetch the session token via this endpoint at startup and replay it + // on subsequent unsafe requests through axios. + g.GET("/csrf-token", a.csrfToken) + a.settingController = NewSettingController(g) a.xraySettingController = NewXraySettingController(g) } @@ -55,3 +64,15 @@ func (a *XUIController) settings(c *gin.Context) { func (a *XUIController) xraySettings(c *gin.Context) { html(c, "xray.html", "pages.xray.title", nil) } + +// csrfToken returns the session CSRF token to authenticated SPA clients. +// The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself, +// but checkLogin still gates the response — anonymous callers get 401/redirect. +func (a *XUIController) csrfToken(c *gin.Context) { + token, err := session.EnsureCSRFToken(c) + if err != nil { + c.JSON(http.StatusInternalServerError, entity.Msg{Success: false, Msg: err.Error()}) + return + } + c.JSON(http.StatusOK, entity.Msg{Success: true, Obj: token}) +}