fix(auth): make logout POST+CSRF and propagate session loss to other tabs

- Switch /logout from GET to POST with CSRFMiddleware so it matches the
  SPA's existing HttpUtil.post('/logout') call (previously 404'd silently)
  and blocks GET-based logout via image tags or link prefetchers. Handler
  now returns JSON; the SPA already navigates client-side.
- Return 401 (instead of 404) from /panel/api/* when the caller is a
  browser XHR (X-Requested-With: XMLHttpRequest) so the axios interceptor
  redirects to the login page on logout-in-another-tab, cookie expiry,
  and server restart. Anonymous callers still get 404 to keep endpoints
  hidden from casual scanners.
- One-shot the 401 redirect in axios-init.js and hang the rejected
  promise so queued polls don't stack reloads or surface error toasts
  while the browser is navigating away.
- Add the CSP nonce to the runtime-injected <script> in dist.go so the
  panel loads under the existing script-src 'nonce-...' policy.
- Update api-docs endpoints.js: GET /logout doc entry was missing.
This commit is contained in:
MHSanaei 2026-05-13 12:24:05 +02:00
parent 364c5a35e3
commit 4e7687e2fe
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 24 additions and 45 deletions

View file

@ -2,24 +2,16 @@ import axios from 'axios';
import qs from 'qs'; import qs from 'qs';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']); 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'; const CSRF_TOKEN_PATH = '/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 csrfToken = null;
let csrfFetchPromise = null; let csrfFetchPromise = null;
let sessionExpired = false;
function readMetaToken() { function readMetaToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null; 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() { async function fetchCsrfToken() {
try { try {
const basePath = window.X_UI_BASE_PATH; const basePath = window.X_UI_BASE_PATH;
@ -91,19 +83,16 @@ export function setupAxios() {
async (error) => { async (error) => {
const status = error.response?.status; const status = error.response?.status;
if (status === 401) { if (status === 401) {
// 401 → session is gone. In production, the panel routes if (!sessionExpired) {
// are gated by Go's checkLogin which redirects to base_path sessionExpired = true;
// 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) { if (import.meta.env.DEV) {
const basePath = window.X_UI_BASE_PATH || '/'; const basePath = window.X_UI_BASE_PATH || '/';
window.location.href = `${basePath}login.html`; window.location.href = `${basePath}login.html`;
} else { } else {
window.location.reload(); window.location.reload();
} }
return Promise.reject(error); }
return new Promise(() => { });
} }
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once. // 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
const cfg = error.config; const cfg = error.config;

View file

@ -49,6 +49,7 @@ export const sections = [
method: 'POST', method: 'POST',
path: '/logout', path: '/logout',
summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.', summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.',
response: '{\n "success": true\n}',
}, },
{ {
method: 'GET', method: 'GET',

View file

@ -29,25 +29,11 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
return a 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 <apiToken> — 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) { func (a *APIController) checkAPIAuth(c *gin.Context) {
auth := c.GetHeader("Authorization") auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") { if strings.HasPrefix(auth, "Bearer ") {
tok := strings.TrimPrefix(auth, "Bearer ") tok := strings.TrimPrefix(auth, "Bearer ")
if a.settingService.MatchApiToken(tok) { 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 { if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u) session.SetAPIAuthUser(c, u)
} }
@ -57,7 +43,11 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
} }
} }
if !session.IsLogin(c) { if !session.IsLogin(c) {
if c.GetHeader("X-Requested-With") == "XMLHttpRequest" {
c.AbortWithStatus(http.StatusUnauthorized)
} else {
c.AbortWithStatus(http.StatusNotFound) c.AbortWithStatus(http.StatusNotFound)
}
return return
} }
c.Next() c.Next()

View file

@ -57,7 +57,11 @@ func serveDistPage(c *gin.Context, name string) {
} }
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`) csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
script := `<script>window.X_UI_BASE_PATH="` + escapedBase + `"` nonceAttr := ""
if nonce := c.GetString("csp_nonce"); nonce != "" {
nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
}
script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
if name != "login.html" { if name != "login.html" {
escapedVer := jsEscape.Replace(config.GetVersion()) escapedVer := jsEscape.Replace(config.GetVersion())
script += `;window.X_UI_CUR_VER="` + escapedVer + `"` script += `;window.X_UI_CUR_VER="` + escapedVer + `"`

View file

@ -39,15 +39,10 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
// initRouter sets up the routes for index, login, logout, and two-factor authentication. // initRouter sets up the routes for index, login, logout, and two-factor authentication.
func (a *IndexController) initRouter(g *gin.RouterGroup) { func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index) g.GET("/", a.index)
g.GET("/logout", a.logout)
// Public CSRF endpoint — the SPA login page (served by Vite in
// dev or by serveDistPage in prod) needs a token to POST /login,
// but the panel-side /panel/csrf-token sits behind checkLogin.
// EnsureCSRFToken creates a session token even for anonymous
// callers, so any pre-login flow can bootstrap from here.
g.GET("/csrf-token", a.csrfToken) g.GET("/csrf-token", a.csrfToken)
g.POST("/login", middleware.CSRFMiddleware(), a.login) g.POST("/login", middleware.CSRFMiddleware(), a.login)
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable) g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
} }
@ -140,7 +135,7 @@ func loginFailureReason(err error) string {
return "invalid credentials" return "invalid credentials"
} }
// logout handles user logout by clearing the session and redirecting to the login page. // logout clears the session. The SPA performs the navigation client-side.
func (a *IndexController) logout(c *gin.Context) { func (a *IndexController) logout(c *gin.Context) {
user := session.GetLoginUser(c) user := session.GetLoginUser(c)
if user != nil { if user != nil {
@ -150,7 +145,7 @@ func (a *IndexController) logout(c *gin.Context) {
logger.Warning("Unable to clear session on logout:", err) logger.Warning("Unable to clear session on logout:", err)
} }
c.Header("Cache-Control", "no-store") c.Header("Cache-Control", "no-store")
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path")) c.JSON(http.StatusOK, gin.H{"success": true})
} }
// csrfToken returns the session CSRF token. Public — the login page // csrfToken returns the session CSRF token. Public — the login page