diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js
index cae11195..95f5c689 100644
--- a/frontend/src/api/axios-init.js
+++ b/frontend/src/api/axios-init.js
@@ -2,24 +2,16 @@ import axios from 'axios';
import qs from 'qs';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
-// Public CSRF endpoint — works pre-login (the panel-scoped
-// /panel/csrf-token sits behind checkLogin and would 401 a fresh
-// login page that hasn't authenticated yet).
const CSRF_TOKEN_PATH = '/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;
+let sessionExpired = false;
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 basePath = window.X_UI_BASE_PATH;
@@ -91,19 +83,16 @@ export function setupAxios() {
async (error) => {
const status = error.response?.status;
if (status === 401) {
- // 401 → session is gone. In production, the panel routes
- // are gated by Go's checkLogin which redirects to base_path
- // serving the login page; a reload is enough. In dev, Vite
- // serves /index.html directly at "/", so a reload would put
- // the user right back on the dashboard and the interceptor
- // would loop. Navigate to the dev login entry instead.
- if (import.meta.env.DEV) {
- const basePath = window.X_UI_BASE_PATH || '/';
- window.location.href = `${basePath}login.html`;
- } else {
- window.location.reload();
+ if (!sessionExpired) {
+ sessionExpired = true;
+ if (import.meta.env.DEV) {
+ const basePath = window.X_UI_BASE_PATH || '/';
+ window.location.href = `${basePath}login.html`;
+ } else {
+ window.location.reload();
+ }
}
- return Promise.reject(error);
+ return new Promise(() => { });
}
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
const cfg = error.config;
diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js
index 86658bd2..c92ee389 100644
--- a/frontend/src/pages/api-docs/endpoints.js
+++ b/frontend/src/pages/api-docs/endpoints.js
@@ -49,6 +49,7 @@ export const sections = [
method: 'POST',
path: '/logout',
summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.',
+ response: '{\n "success": true\n}',
},
{
method: 'GET',
diff --git a/web/controller/api.go b/web/controller/api.go
index 8aaeaefa..9344541a 100644
--- a/web/controller/api.go
+++ b/web/controller/api.go
@@ -29,25 +29,11 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
return a
}
-// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests
-// to hide the existence of API endpoints from unauthorized users.
-//
-// Two auth paths are accepted:
-// 1. Authorization: Bearer — used by remote central panels
-// polling this instance as a node. Matches via constant-time compare.
-// Sets c.Set("api_authed", true) so CSRFMiddleware can short-circuit.
-// 2. Existing session cookie — used by browsers logged into the panel UI.
-//
-// Anything else falls through to a 404 so the API endpoints remain hidden.
func (a *APIController) checkAPIAuth(c *gin.Context) {
auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
tok := strings.TrimPrefix(auth, "Bearer ")
if a.settingService.MatchApiToken(tok) {
- // Handlers like InboundController.addInbound assume a logged-in
- // user (inbound.UserId = user.Id). Bearer callers have no
- // session, so attach the first user as a fallback. Single-user
- // panels are the norm here.
if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u)
}
@@ -57,7 +43,11 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
}
}
if !session.IsLogin(c) {
- c.AbortWithStatus(http.StatusNotFound)
+ if c.GetHeader("X-Requested-With") == "XMLHttpRequest" {
+ c.AbortWithStatus(http.StatusUnauthorized)
+ } else {
+ c.AbortWithStatus(http.StatusNotFound)
+ }
return
}
c.Next()
diff --git a/web/controller/dist.go b/web/controller/dist.go
index 51bd3574..fd1b35a9 100644
--- a/web/controller/dist.go
+++ b/web/controller/dist.go
@@ -57,7 +57,11 @@ func serveDistPage(c *gin.Context, name string) {
}
csrfMeta := []byte(``)
- script := `