diff --git a/docs/architecture.md b/docs/architecture.md index 97be72b..59e0b5d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,12 +62,12 @@ The `lib/` modules auto-discover the project root at runtime via `Path(__file__) ## JWT Token Model -Vacuum Wall uses JWT-based authentication with access/refresh token rotation. Tokens are stored in browser `localStorage` and injected as `Authorization: Bearer ` headers. The API never reads cookies — authentication is header-only. +Vacuum Wall uses JWT-based authentication with access/refresh token rotation. Tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer ` headers. The API never reads cookies — authentication is header-only. | Token | Lifetime | Storage | Purpose | |---|---|---|---| -| Access | 15 min | localStorage | API auth, permission checks | -| Refresh | 7 days | localStorage | Token rotation, new access tokens | +| Access | 15 min | sessionStorage / memory | API auth, permission checks | +| Refresh | 7 days | sessionStorage | Token rotation, new access tokens | JWT payload contains `sub` (username), `exp` (expiry), `iat` (issued at), `jti` (unique identifier), `type` (`"access"` or `"refresh"`), `permissions` (per-subsystem permissions), and `session_id` (session binding). Access tokens additionally contain `permissions` and `session_id`. diff --git a/docs/security.md b/docs/security.md index 4aa781d..bec887b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -72,7 +72,7 @@ The `daemon/client.py` module resolves `` placeholders in URL paths befor The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain. -JWT tokens are stored in browser `localStorage` and injected as `Authorization: Bearer ` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers. +JWT tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer ` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers. The management interface does not set security hardening headers (e.g., `X-Content-Type-Options`, `X-Frame-Options`, HSTS) on proxied responses, as the SPA requires flexibility for its operation. It relies on JWT authentication, SSL termination, and the systemd sandbox for its security boundary. @@ -122,7 +122,7 @@ The API exclusively reads the `Authorization` header — never cookies. This arc - No cookie-based session to exploit - No SameSite, double-submit, or origin checking needed -**XSS as the primary attack surface**: With header-only auth, XSS is the primary attack vector since `localStorage` is accessible to page scripts. Mitigations include: +**XSS as the primary attack surface**: With header-only auth, XSS is the primary attack vector since `sessionStorage` is accessible to page scripts. Mitigations include: - CSP headers on the management domain (configured in nginx) - `X-XSS-Protection` header - Short-lived access tokens (15 min) with blacklist on logout diff --git a/webui/static/hoover/components/auth.js b/webui/static/hoover/components/auth.js index 703d63c..786f838 100644 --- a/webui/static/hoover/components/auth.js +++ b/webui/static/hoover/components/auth.js @@ -166,7 +166,11 @@ function b64urlToArrayBuffer(b64url) { */ function arrayBufferToB64url(buffer) { const bytes = new Uint8Array(buffer); - const bin = String.fromCharCode.apply(null, Array.from(bytes)); + const chunks = []; + for (let i = 0; i < bytes.length; i += 0x8000) { + chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 0x8000))); + } + const bin = chunks.join(''); return btoa(bin) .replace(/\+/g, '-') .replace(/\//g, '_') diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index 2109f13..a1403f2 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -13,6 +13,7 @@ let _wsConn = null; let _wsReconnectMs = 0; let _wsFailCount = 0; let _wsRefreshing = false; +let _wsClosingHandled = false; /** Direct onMessage handlers — { topics, handler, unsubscribed }[] */ const _directHandlers = []; @@ -35,6 +36,7 @@ function _wsUrl() { function _wsConnect() { if (_wsConn && _wsConn.readyState <= 1) return; + _wsClosingHandled = false; const token = window.__auth_token__; if (token) { _wsConn = new WebSocket(_wsUrl(), ['Bearer ' + token]); @@ -46,9 +48,12 @@ function _wsConnect() { _wsReconnectMs = 0; _wsFailCount = 0; _wsRefreshing = false; + _wsClosingHandled = false; }; _wsConn.onclose = () => { + if (_wsClosingHandled) return; + _wsClosingHandled = true; if (!window.__auth_token__) return; _wsFailCount++; diff --git a/webui/static/pages/users.js b/webui/static/pages/users.js index fa92e66..ed8b1fb 100644 --- a/webui/static/pages/users.js +++ b/webui/static/pages/users.js @@ -64,6 +64,7 @@ async function loadUsers(abortController) { state.refreshing = false; } } +} function permissionLevel(perms, subsystem) { return perms[subsystem] || '—';