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