diff --git a/AppImage/ProxMenux-1.2.1.1-beta.AppImage b/AppImage/ProxMenux-1.2.1.1-beta.AppImage index 7d65f661..15ae08af 100755 Binary files a/AppImage/ProxMenux-1.2.1.1-beta.AppImage and b/AppImage/ProxMenux-1.2.1.1-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 4b46045b..398f8156 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -78dee82f335a54bd82a22f778de7e5391272b91c28bc7cfd1bffcd9091c62395 /tmp/ProxMenux-1.2.1.1-beta.AppImage +a1a3e1d1f028a837efc34ef3507a03a8a899e244824381ef3ae3b3e60a5a8396 /tmp/ProxMenux-1.2.1.1-beta.AppImage diff --git a/AppImage/app/page.tsx b/AppImage/app/page.tsx index f2b8e2a7..810f2766 100644 --- a/AppImage/app/page.tsx +++ b/AppImage/app/page.tsx @@ -65,6 +65,21 @@ export default function Home() { const authenticated = data.auth_enabled ? data.authenticated : true + // Clear the 401 cascade-prevention flag when we successfully end + // up in the authenticated state. The flag is meant to dedupe a + // burst of 401s during a single page load; once we've confirmed + // the user is in, a future 401 (token rotation, restart, etc.) + // should be allowed to reload again. Without this, a stale flag + // can prevent the post-2FA dashboard from recovering from any + // transient 401 and leaves the UI blocked. + if (authenticated) { + try { + sessionStorage.removeItem("proxmenux-auth-401-handled") + } catch { + // private browsing — best-effort + } + } + setAuthStatus({ loading: false, authEnabled: data.auth_enabled, diff --git a/AppImage/components/login.tsx b/AppImage/components/login.tsx index bef41889..ce61d472 100644 --- a/AppImage/components/login.tsx +++ b/AppImage/components/login.tsx @@ -26,6 +26,21 @@ export function Login({ onLogin }: LoginProps) { const [loading, setLoading] = useState(false) useEffect(() => { + // The Login screen is, by construction, the recovery path from any + // 401 cascade (the api-config wrapper redirects here when an + // expired/invalid JWT is detected). Clear the cascade-prevention + // flag on mount so a successful login can subsequently fire a fresh + // reload if a NEW 401 ever occurs. Without this clear, any 401 set + // earlier in the session sticks around forever and the next 401 + // (e.g. mid-2FA, or right after a successful login if the token was + // briefly stale) is silently swallowed by the de-dup — the user + // sees a blank/stuck dashboard. + try { + sessionStorage.removeItem("proxmenux-auth-401-handled") + } catch { + // private browsing — best-effort + } + const savedUsername = localStorage.getItem("proxmenux-saved-username") const savedPassword = localStorage.getItem("proxmenux-saved-password") diff --git a/AppImage/lib/api-config.ts b/AppImage/lib/api-config.ts index afeb2be7..9d998304 100644 --- a/AppImage/lib/api-config.ts +++ b/AppImage/lib/api-config.ts @@ -95,12 +95,27 @@ export async function fetchApi(endpoint: string, options?: RequestInit): Prom // (rotated per-install). Drop the stale token and force a single // reload so the page-level auth gate (`app/page.tsx`) can render // instead of cascading 401s from every authenticated - // component on mount. The sessionStorage flag is essential: a - // page like Hardware/Storage fires 10-20 SWR fetches in parallel, - // and without dedup each of them would race to reload the tab — - // observed in the wild as ~180 "Invalid token" log lines per - // second from a single browser running an upgraded Monitor. + // component on mount. + // + // Only react when we actually had a token to invalidate. A 401 + // without any token in localStorage means the caller is the + // Login screen itself, or a leftover fetch from a recently + // unmounted Dashboard — reloading there does nothing but waste + // the user's keystrokes and can leave the cascade flag set + // forever, swallowing the very 401 that we'd want to recover + // from after a successful re-login. The fix: bail out early + // if we have no token to invalidate. if (typeof window !== "undefined") { + let hadToken = false + try { + hadToken = !!localStorage.getItem("proxmenux-auth-token") + } catch { + // private browsing — assume yes so we attempt recovery. + hadToken = true + } + if (!hadToken) { + throw new Error(`Unauthorized: ${endpoint}`) + } try { localStorage.removeItem("proxmenux-auth-token") } catch { diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index 3775662d..58072281 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -191,24 +191,6 @@ def _bad_request(msg: str): return jsonify({'error': msg}), 400 -def _is_loopback_addr(value: str) -> bool: - """Return True for IPv4, IPv6 and IPv4-mapped loopback addresses. - - When Flask is bound to ``::`` for dual-stack support, an HTTP request - sent to ``127.0.0.1`` can be reported as ``::ffff:127.0.0.1``. Treat it - as local so the PVE webhook keeps the intended localhost trust path. - """ - try: - import ipaddress - addr = ipaddress.ip_address(value) - if addr.is_loopback: - return True - ipv4_mapped = getattr(addr, 'ipv4_mapped', None) - return bool(ipv4_mapped and ipv4_mapped.is_loopback) - except ValueError: - return value == 'localhost' - - def _validate_event_type(value: str) -> bool: return isinstance(value, str) and bool(_EVENT_TYPE_RE.match(value)) @@ -1243,7 +1225,7 @@ def proxmox_webhook(): _reject = lambda code, error, status: (jsonify({'accepted': False, 'error': error}), status) client_ip = request.remote_addr or '' - is_localhost = _is_loopback_addr(client_ip) + is_localhost = client_ip in ('127.0.0.1', '::1') # CSRF defence-in-depth: reject `application/x-www-form-urlencoded` # bodies. PVE always sends `application/json`; form-encoded bodies