From b69ca330f4c5006bca45d3e5fd2ccf988f694c63 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Thu, 30 Jul 2026 22:55:16 +0000 Subject: [PATCH] enforce mandatory X-Session-Id header for access token validation Session binding was bypassable: if the X-Session-Id header was absent, validate_token skipped the check entirely, allowing a stolen JWT to be used without the originating session. Server-side: reject 401 early in Flask middleware and daemon WebSocket handler when X-Session-Id is missing, before calling validate_token. Updated validate_token to always enforce session_id matching for access tokens (refresh tokens are unaffected as they carry no session_id claim). Frontend: removed dead if (stored.session_id) guards in api.js since the header is now always required. Added X-Session-Id to logout request headers and always store session_id on login/refresh. --- daemon/server.py | 2 ++ lib/auth.py | 16 +++++++++----- tests/test_auth.py | 30 ++++++++++++++++---------- webui/server.py | 2 ++ webui/static/hoover/api.js | 12 +++-------- webui/static/hoover/components/auth.js | 5 ++--- 6 files changed, 39 insertions(+), 28 deletions(-) diff --git a/daemon/server.py b/daemon/server.py index b62a833..a008855 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -397,6 +397,8 @@ async def _handle_ws(request: web.Request) -> web.Response: ) session_header = request.headers.get("X-Session-Id") + if not session_header: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) payload = validate_token( token_param, token_type="access", session_id=session_header ) diff --git a/lib/auth.py b/lib/auth.py index b2ac916..afae780 100644 --- a/lib/auth.py +++ b/lib/auth.py @@ -289,10 +289,10 @@ def validate_token( Args: token_string: The JWT token string. token_type: Expected token type ("access" or "refresh"). - session_id: If provided, must match the ``session_id`` claim in the - token payload. Acts as session binding — prevents a stolen token - from being used by an attacker who doesn't also possess the - matching session ID. + session_id: Must match the ``session_id`` claim in the token payload. + Required for access tokens — prevents a stolen token from being + usable without the originating session. Ignored for refresh tokens + which carry no ``session_id`` claim. Returns: Payload dict including permissions, or None if invalid/blacklisted. @@ -302,7 +302,13 @@ def validate_token( return None if payload.get("type") != token_type: return None - if session_id and payload.get("session_id") != session_id: + if token_type == "access" and session_id != payload.get("session_id"): + return None + if ( + token_type != "access" + and session_id + and payload.get("session_id") != session_id + ): return None jti = payload.get("jti") diff --git a/tests/test_auth.py b/tests/test_auth.py index 8713e1a..3c2d6c2 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -235,13 +235,13 @@ class TestJWT: payload = validate_token(token, "access", session_id="wrong-session") assert payload is None - def test_session_id_optional(self): - """Token validates without session_id when none is required.""" + def test_session_id_required(self): + """Access token is rejected without session_id parameter.""" token = generate_access_token( "admin", {"firewall": "rw"}, session_id="my-session" ) payload = validate_token(token, "access") - assert payload is not None + assert payload is None def test_decode_token(self): token = generate_access_token("admin", {"firewall": "rw"}) @@ -251,23 +251,27 @@ class TestJWT: assert payload["type"] == "access" def test_validate_access_token(self): - token = generate_access_token("admin", {"firewall": "rw"}) - payload = validate_token(token, "access") + token = generate_access_token( + "admin", {"firewall": "rw"}, session_id="test-session" + ) + payload = validate_token(token, "access", session_id="test-session") assert payload is not None assert payload["sub"] == "admin" assert payload["permissions"]["firewall"] == "rw" def test_validate_wrong_type(self): token = generate_refresh_token("admin") - payload = validate_token(token, "access") + payload = validate_token(token, "access", session_id="test-session") assert payload is None def test_validate_invalid_token(self): - payload = validate_token("invalid.token.here", "access") + payload = validate_token( + "invalid.token.here", "access", session_id="test-session" + ) assert payload is None def test_blacklist_token(self): - token = generate_access_token("admin", {}) + token = generate_access_token("admin", {}, session_id="test-session") payload = decode_token(token) assert payload is not None jti = payload["jti"] @@ -275,7 +279,7 @@ class TestJWT: blacklist_token(jti) assert is_blacklisted(jti) is True - result = validate_token(token, "access") + result = validate_token(token, "access", session_id="test-session") assert result is None def test_blacklist_cleanup(self): @@ -373,7 +377,9 @@ class TestLoginFlow: assert verify_user_password("testadmin", "secretpass") is not None tokens = generate_tokens("testadmin", user["permissions"]) - payload = validate_token(tokens["access_token"], "access") + payload = validate_token( + tokens["access_token"], "access", session_id=tokens["session_id"] + ) assert payload is not None assert payload["sub"] == "testadmin" assert payload["permissions"]["firewall"] == "rw" @@ -387,7 +393,9 @@ class TestLoginFlow: blacklist_token(payload["jti"]) - result = validate_token(tokens["access_token"], "access") + result = validate_token( + tokens["access_token"], "access", session_id=tokens["session_id"] + ) assert result is None def test_token_refresh_flow(self): diff --git a/webui/server.py b/webui/server.py index 79427e6..c78d1a4 100644 --- a/webui/server.py +++ b/webui/server.py @@ -196,6 +196,8 @@ def _auth_middleware(): token_string = auth_header[7:] # strip "Bearer " session_header = request.headers.get("X-Session-Id") + if not session_header: + return jsonify({"ok": False, "error": "unauthorized"}), 401 payload = validate_token( token_string, token_type="access", session_id=session_header ) diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 4bae2f8..388b280 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -107,9 +107,7 @@ async function tryRefreshToken() { window.__auth_token__ = tokens.access_token; sessionStorage.setItem('vw:refresh', tokens.refresh_token); sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000)); - if (tokens.session_id) { - sessionStorage.setItem('vw:session_id', tokens.session_id); - } + sessionStorage.setItem('vw:session_id', tokens.session_id); if (json.data.user) { sessionStorage.setItem('vw:user', JSON.stringify(json.data.user)); } @@ -151,9 +149,7 @@ export async function apiFetch(url, options = {}) { if (token) { headers['Authorization'] = 'Bearer ' + token; const stored = getStoredAuth(); - if (stored.session_id) { - headers['X-Session-Id'] = stored.session_id; - } + headers['X-Session-Id'] = stored.session_id; } if (body && typeof body === 'object' && !(body instanceof FormData)) { @@ -171,9 +167,7 @@ export async function apiFetch(url, options = {}) { if (refreshed) { const refreshedStored = getStoredAuth(); headers['Authorization'] = 'Bearer ' + getAuthToken(); - if (refreshedStored.session_id) { - headers['X-Session-Id'] = refreshedStored.session_id; - } + headers['X-Session-Id'] = refreshedStored.session_id; const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); if (retryRes.ok) { const json = await retryRes.json().catch(() => null); diff --git a/webui/static/hoover/components/auth.js b/webui/static/hoover/components/auth.js index 44eef3f..f05a601 100644 --- a/webui/static/hoover/components/auth.js +++ b/webui/static/hoover/components/auth.js @@ -67,6 +67,7 @@ export async function logout() { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Bearer ' + token, + 'X-Session-Id': sessionStorage.getItem('vw:session_id'), }; const refresh = sessionStorage.getItem('vw:refresh'); await fetch('/api/auth/logout', { @@ -120,9 +121,7 @@ export function handleLoginSuccess(data, redirectPath = '/dashboard') { setAuthToken(tokens.access_token); sessionStorage.setItem('vw:refresh', tokens.refresh_token); sessionStorage.setItem('vw:access_ttl', String((data.access_ttl || 300) * 1000)); - if (tokens.session_id) { - sessionStorage.setItem('vw:session_id', tokens.session_id); - } + sessionStorage.setItem('vw:session_id', tokens.session_id); if (user) { sessionStorage.setItem('vw:user', JSON.stringify(user)); if (permissions) {