mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
fix(csrf): expose token endpoint for SPA pages and fetch it from axios
The legacy panel pages got their CSRF token from a <meta name="csrf-token"> tag rendered by Go. SPA pages built by Vite don't have that, so every unsafe (POST/PUT/DELETE) request from them was hitting CSRFMiddleware with no token and getting 403 — visible as the settings page being stuck on "Loading…" because POST /panel/setting/all failed. - web/controller/xui.go: GET /panel/csrf-token returns the session token. Lives under the xui group so checkLogin still gates it; the CSRFMiddleware on the same group is a no-op for GET. - frontend/src/api/axios-init.js: cache the token at module scope and lazy-fetch it when a non-safe request needs one. Seed from the meta tag first when present (legacy compat). On a 403 response, drop the cache and retry once — handles the case where a server restart rotated the token after the SPA loaded. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f773f85cf9
commit
3ecdae7c92
2 changed files with 94 additions and 7 deletions
|
|
@ -1,19 +1,68 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import qs from 'qs';
|
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
|
||||||
|
// <meta name="csrf-token"> 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
|
// Apply the panel's axios defaults + interceptors. Call once at app
|
||||||
// startup before any HTTP call goes out.
|
// startup before any HTTP call goes out.
|
||||||
export function setupAxios() {
|
export function setupAxios() {
|
||||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
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(
|
axios.interceptors.request.use(
|
||||||
(config) => {
|
async (config) => {
|
||||||
config.headers = config.headers || {};
|
config.headers = config.headers || {};
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
||||||
const method = (config.method || 'get').toUpperCase();
|
const method = (config.method || 'get').toUpperCase();
|
||||||
if (csrfToken && !['GET', 'HEAD', 'OPTIONS', 'TRACE'].includes(method)) {
|
if (!SAFE_METHODS.has(method)) {
|
||||||
config.headers['X-CSRF-Token'] = csrfToken;
|
const token = await ensureCsrfToken();
|
||||||
|
if (token) config.headers['X-CSRF-Token'] = token;
|
||||||
}
|
}
|
||||||
if (config.data instanceof FormData) {
|
if (config.data instanceof FormData) {
|
||||||
config.headers['Content-Type'] = 'multipart/form-data';
|
config.headers['Content-Type'] = 'multipart/form-data';
|
||||||
|
|
@ -27,9 +76,26 @@ export function setupAxios() {
|
||||||
|
|
||||||
axios.interceptors.response.use(
|
axios.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (error.response && error.response.status === 401) {
|
const status = error.response?.status;
|
||||||
return window.location.reload();
|
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);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
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/middleware"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
@ -32,6 +36,11 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||||
g.GET("/settings", a.settings)
|
g.GET("/settings", a.settings)
|
||||||
g.GET("/xray", a.xraySettings)
|
g.GET("/xray", a.xraySettings)
|
||||||
|
|
||||||
|
// SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">,
|
||||||
|
// 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.settingController = NewSettingController(g)
|
||||||
a.xraySettingController = NewXraySettingController(g)
|
a.xraySettingController = NewXraySettingController(g)
|
||||||
}
|
}
|
||||||
|
|
@ -55,3 +64,15 @@ func (a *XUIController) settings(c *gin.Context) {
|
||||||
func (a *XUIController) xraySettings(c *gin.Context) {
|
func (a *XUIController) xraySettings(c *gin.Context) {
|
||||||
html(c, "xray.html", "pages.xray.title", nil)
|
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})
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue