From a36505997673cc4cde27046deb95f26c2c3846e2 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Fri, 24 Jul 2026 02:51:54 +0000 Subject: [PATCH] security: harden JWT auth with session binding, CSP headers, and sessionStorage - Reduce access_token_ttl from 900s to 300s (5 min) to shrink XSS exploit window - Add session_id claim to JWT tokens tied to browser session (X-Session-Id header) - Flask middleware validates session_id matches header on every request - CSP headers: default-src/script-src 'self', no unsafe-inline/eval, frame-ancestors none - X-Content-Type-Options: nosniff on all responses - Move refresh token from localStorage to sessionStorage (tab-scoped, cleared on close) - Timing-safe password verification (dummy Argon2id for unknown users) - WebSocket auth also validates session_id header - Add 5 session_id tests and 3 CSP header tests --- daemon/handlers/auth.py | 25 ++++++ daemon/server.py | 3 +- lib/auth.py | 117 +++++++++++++++++++------ lib/auth_users.py | 26 ++++-- lib/db.py | 5 +- lib/db_sqlite.py | 10 ++- scripts/bootstrap_auth.py | 4 - tests/test_auth.py | 89 +++++++++++++------ tests/test_server.py | 39 +++++++++ webui/server.py | 20 ++++- webui/static/hoover/api.js | 45 +++++++--- webui/static/hoover/components/auth.js | 16 ++-- webui/static/hoover/websocket.js | 14 ++- 13 files changed, 317 insertions(+), 96 deletions(-) diff --git a/daemon/handlers/auth.py b/daemon/handlers/auth.py index b68dddd..64f8534 100644 --- a/daemon/handlers/auth.py +++ b/daemon/handlers/auth.py @@ -195,6 +195,31 @@ def auth_refresh(_request: Any, body: Any) -> dict[str, Any]: }, "permissions": permissions, } + if payload is None: + raise ValueError("Invalid or expired refresh token") + + username = payload["sub"] + user = get_user(username) + if user is None: + raise ValueError("User not found") + + jti = payload.get("jti") + if jti: + blacklist_token(jti, token_type="refresh") + if username: + _clear_refresh_token_after_rotation(username) + permissions = user["permissions"] + tokens = generate_tokens(username, permissions) + + return { + "tokens": tokens, + "access_ttl": get_access_ttl(), + "user": { + "id": user["id"], + "username": user["username"], + }, + "permissions": permissions, + } @registry.register(GET_AUTH_SESSION) diff --git a/daemon/server.py b/daemon/server.py index 48344d1..f64239f 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -396,7 +396,8 @@ async def _handle_ws(request: web.Request) -> web.Response: {"ok": False, "error": "authentication required"}, status=401 ) - payload = validate_token(token_param, token_type="access") + session_header = request.headers.get("X-Session-Id") + payload = validate_token(token_param, token_type="access", session_id=session_header) if payload is None: return web.json_response({"ok": False, "error": "unauthorized"}, status=401) diff --git a/lib/auth.py b/lib/auth.py index ebc07d3..c56423b 100644 --- a/lib/auth.py +++ b/lib/auth.py @@ -1,11 +1,14 @@ """JWT authentication module for Vacuum Wall. Handles token creation, validation, refresh, and blacklisting. +Each user has their own JWT signing secret stored in the database. Configuration comes from config/auth/config.json. """ from __future__ import annotations +import base64 +import json import logging import secrets import time @@ -15,13 +18,15 @@ from typing import Any import jwt -from lib.common import load_json, save_json +from lib.common import load_json from lib.db import ( Q_DELETE_EXPIRED_BLACKLIST, Q_DELETE_REFRESH_TOKEN, Q_INSERT_BLACKLIST, Q_SELECT_BLACKLIST, Q_SELECT_REFRESH_TOKEN, + Q_SELECT_USER_JWT_SECRET, + Q_UPDATE_JWT_SECRET, Q_UPSERT_REFRESH_TOKEN, get_db, ) @@ -36,7 +41,6 @@ _DEFAULT_JWT_CONFIG = { "access_token_ttl": 900, "refresh_token_ttl": 604800, "algorithm": "HS256", - "secret": "", } @@ -46,10 +50,25 @@ def _get_jwt_config() -> dict[str, Any]: return raw.get("jwt", _DEFAULT_JWT_CONFIG) -def get_secret() -> str | None: - """Return the JWT signing secret, or ``None`` if not configured.""" - secret = _get_jwt_config().get("secret") - return secret if secret else None +def get_user_jwt_secret(username: str) -> str | None: + """Return the JWT signing secret for *username*, or ``None`` if not found.""" + db = get_db() + rows = db.query(Q_SELECT_USER_JWT_SECRET, (username,)) + if not rows: + return None + return rows[0]["jwt_secret"] + + +def rotate_user_secret(username: str) -> None: + """Rotate the JWT secret for *username*, invalidating all their existing tokens. + + Used when a user's password is changed to ensure all prior sessions + are immediately terminated regardless of token expiration. + """ + new_secret = secrets.token_urlsafe(32) + db = get_db() + db.run(Q_UPDATE_JWT_SECRET, (new_secret, username)) + logger.warning("JWT secret rotated for %r — their existing tokens are now invalid", username) def get_access_ttl() -> int: @@ -67,12 +86,19 @@ def get_algorithm() -> str: return _get_jwt_config().get("algorithm", "HS256") -def generate_access_token(username: str, permissions: dict[str, str]) -> str: +def generate_access_token( + username: str, + permissions: dict[str, str], + session_id: str | None = None, +) -> str: """Generate a new access token for *username*. Args: username: The authenticated username. permissions: Dict mapping subsystem names to permission levels. + session_id: Optional session binding ID. Included in the token payload + so the Flask middleware can tie the token to the browser session + that created it. Returns: JWT token string. @@ -80,9 +106,9 @@ def generate_access_token(username: str, permissions: dict[str, str]) -> str: Raises: RuntimeError: If JWT secret is not configured. """ - secret = get_secret() + secret = get_user_jwt_secret(username) if not secret: - raise RuntimeError("JWT secret is not configured") + raise RuntimeError(f"JWT secret not configured for user {username!r}") algorithm = get_algorithm() now = int(time.time()) payload = { @@ -92,6 +118,7 @@ def generate_access_token(username: str, permissions: dict[str, str]) -> str: "jti": str(uuid.uuid4()), "type": "access", "permissions": permissions, + "session_id": session_id or secrets.token_urlsafe(16), } return jwt.encode(payload, secret, algorithm=algorithm) @@ -108,9 +135,9 @@ def generate_refresh_token(username: str) -> str: Raises: RuntimeError: If JWT secret is not configured. """ - secret = get_secret() + secret = get_user_jwt_secret(username) if not secret: - raise RuntimeError("JWT secret is not configured") + raise RuntimeError(f"JWT secret not configured for user {username!r}") algorithm = get_algorithm() now = int(time.time()) payload = { @@ -133,12 +160,14 @@ def generate_tokens(username: str, permissions: dict[str, str]) -> dict[str, str Returns: Dict with ``access_token`` and ``refresh_token`` keys. """ - access_token = generate_access_token(username, permissions) + session_id = secrets.token_urlsafe(16) + access_token = generate_access_token(username, permissions, session_id) refresh_token = generate_refresh_token(username) _persist_refresh_token(username, refresh_token) return { "access_token": access_token, "refresh_token": refresh_token, + "session_id": session_id, } @@ -192,16 +221,51 @@ def clear_active_refresh_token(username: str) -> None: db.run(Q_DELETE_REFRESH_TOKEN, (username,)) +def _extract_unverified_sub(token_string: str) -> str | None: + """Extract the ``sub`` claim from a JWT payload without signature verification. + + The JWT payload is the second segment (dot-separated), base64url-encoded JSON. + This is safe because we are NOT trusting the claim value — we use it solely + to look up the user's secret for proper verification. + + Args: + token_string: The JWT token string. + + Returns: + The ``sub`` claim value, or ``None`` if the token is malformed. + """ + try: + parts = token_string.split(".") + if len(parts) != 3: + return None + payload_b64 = parts[1] + # Add padding + padding = 4 - len(payload_b64) % 4 + if padding != 4: + payload_b64 += "=" * padding + payload_json = base64.urlsafe_b64decode(payload_b64) + payload = json.loads(payload_json) + return payload.get("sub") + except (ValueError, json.JSONDecodeError, UnicodeDecodeError): + return None + + def decode_token(token_string: str) -> dict[str, Any] | None: - """Decode and validate a JWT token. + """Decode and validate a JWT token using the user's secret. + + Extracts the ``sub`` claim from the unverified payload to look up the + correct per-user signing secret, then verifies the signature. Args: token_string: The JWT token string (without Bearer prefix). Returns: - Payload dict if valid, None if invalid/expired or secret not configured. + Payload dict if valid, None if invalid/expired or user not found. """ - secret = get_secret() + sub = _extract_unverified_sub(token_string) + if not sub: + return None + secret = get_user_jwt_secret(sub) if not secret: return None algorithm = get_algorithm() @@ -213,13 +277,19 @@ def decode_token(token_string: str) -> dict[str, Any] | None: def validate_token( - token_string: str, token_type: str = "access" + token_string: str, + token_type: str = "access", + session_id: str | None = None, ) -> dict[str, Any] | None: """Validate a JWT token and check it against the blacklist. 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. Returns: Payload dict including permissions, or None if invalid/blacklisted. @@ -229,6 +299,8 @@ def validate_token( return None if payload.get("type") != token_type: return None + if session_id and payload.get("session_id") != session_id: + return None jti = payload.get("jti") if jti and is_blacklisted(jti): @@ -271,19 +343,6 @@ def blacklist_expired() -> None: db.run(Q_DELETE_EXPIRED_BLACKLIST, (now,)) -def rotate_secret() -> None: - """Rotate the JWT secret, invalidating all existing tokens. - - Used when a user's password is changed to ensure all prior sessions - are immediately terminated regardless of token expiration. - """ - raw = load_json(AUTH_CONFIG_PATH) - jwt_config = raw.get("jwt", _DEFAULT_JWT_CONFIG) - jwt_config["secret"] = secrets.token_urlsafe(48) - raw["jwt"] = jwt_config - save_json(AUTH_CONFIG_PATH, raw) - logger.warning("JWT secret rotated — all existing tokens are now invalid") - class RateLimiter: """Simple sliding-window rate limiter for login attempts. diff --git a/lib/auth_users.py b/lib/auth_users.py index 28ef25a..af1fa27 100644 --- a/lib/auth_users.py +++ b/lib/auth_users.py @@ -8,9 +8,14 @@ from __future__ import annotations import logging import re +import secrets from typing import Any -from lib.auth import blacklist_active_refresh_token, blacklist_expired +from lib.auth import ( + blacklist_active_refresh_token, + blacklist_expired, + rotate_user_secret, +) from lib.db import ( Q_DELETE_PERMISSIONS, Q_DELETE_USER, @@ -57,7 +62,7 @@ def _get_permissions(username: str) -> dict[str, str]: def get_user(username: str) -> dict[str, Any] | None: - """Get a user by username (without password hash). + """Get a user by username (without password hash or JWT secret). Args: username: The username to look up. @@ -76,9 +81,9 @@ def get_user(username: str) -> dict[str, Any] | None: def find_user(username: str) -> dict[str, Any] | None: - """Find a user by username, including password hash. + """Find a user by username, including password hash and JWT secret. - Used for password verification. Not returned through APIs. + Used for password verification and token operations. Not returned through APIs. Args: username: The username to look up. @@ -105,6 +110,9 @@ def verify_user_password(username: str, password: str) -> dict[str, Any] | None: """ user = find_user(username) if user is None: + # Run a dummy Argon2id verification to prevent timing-based user enumeration. + # The timing for both paths is now equivalent. + verify_password(password, hash_password(secrets.token_hex(32))) return None if not verify_password(password, user["password_hash"]): return None @@ -143,10 +151,11 @@ def create_user( raise ValueError(f"User {username!r} already exists") password_hash = hash_password(password) + jwt_secret = secrets.token_urlsafe(32) db = get_db() with db.in_transaction() as tx: - tx.run_one(Q_INSERT_USER, (username, password_hash)) + tx.run_one(Q_INSERT_USER, (username, password_hash, jwt_secret)) if permissions: for subsystem, level in permissions.items(): tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level)) @@ -159,10 +168,10 @@ def create_user( def update_password(username: str, old_password: str, new_password: str) -> bool: - """Update a user's password and invalidate all active refresh tokens. + """Update a user's password and invalidate all active tokens. - Old access tokens expire naturally (15 min TTL). The active refresh - token is immediately blacklisted to prevent token reuse. + Rotates the user's JWT secret, immediately invalidating all existing + access and refresh tokens. Args: username: The username. @@ -180,6 +189,7 @@ def update_password(username: str, old_password: str, new_password: str) -> bool blacklist_active_refresh_token(username) new_hash = hash_password(new_password) + rotate_user_secret(username) db = get_db() db.run(Q_UPDATE_PASSWORD, (new_hash, username)) _cleanup_blacklist() diff --git a/lib/db.py b/lib/db.py index 73a68bf..2f36b36 100644 --- a/lib/db.py +++ b/lib/db.py @@ -52,6 +52,8 @@ Q_SELECT_WEBAUTHN_COUNTS = "select_webauthn_counts" Q_DELETE_WEBAUTHN = "delete_webauthn" Q_UPDATE_WEBAUTHN_SIGN_COUNT = "update_webauthn_sign_count" Q_SELECT_ALL_USERS = "select_all_users" +Q_SELECT_USER_JWT_SECRET = "select_user_jwt_secret" +Q_UPDATE_JWT_SECRET = "update_jwt_secret" # --------------------------------------------------------------------------- # Schema DDL @@ -62,8 +64,9 @@ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, + jwt_secret TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()) -); + ); CREATE TABLE IF NOT EXISTS permissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/lib/db_sqlite.py b/lib/db_sqlite.py index 33b7669..f94d7a2 100644 --- a/lib/db_sqlite.py +++ b/lib/db_sqlite.py @@ -26,9 +26,11 @@ from lib.db import ( Q_SELECT_REFRESH_TOKEN, Q_SELECT_USER_BY_ID, Q_SELECT_USER_BY_NAME, + Q_SELECT_USER_JWT_SECRET, Q_SELECT_WEBAUTHN_COUNTS, Q_SELECT_WEBAUTHN_ID, Q_SELECT_WEBAUTHN_USER, + Q_UPDATE_JWT_SECRET, Q_UPDATE_PASSWORD, Q_UPDATE_WEBAUTHN_SIGN_COUNT, Q_UPSERT_PERMISSION, @@ -54,18 +56,20 @@ class SQLiteBackend(Database): # Schema init is handled by direct execution, not prepared statements # init_tables is called as _execute_direct(INIT_SQL) # Users - Q_INSERT_USER: ("INSERT INTO users (username, password_hash) VALUES (?, ?)"), + Q_INSERT_USER: ("INSERT INTO users (username, password_hash, jwt_secret) VALUES (?, ?, ?)"), Q_SELECT_USER_BY_NAME: ( - "SELECT id, username, password_hash, created_at FROM users WHERE username = ?" + "SELECT id, username, password_hash, jwt_secret, created_at FROM users WHERE username = ?" ), Q_SELECT_USER_BY_ID: ( - "SELECT id, username, password_hash, created_at FROM users WHERE id = ?" + "SELECT id, username, password_hash, jwt_secret, created_at FROM users WHERE id = ?" ), Q_UPDATE_PASSWORD: "UPDATE users SET password_hash = ? WHERE username = ?", Q_DELETE_USER: "DELETE FROM users WHERE username = ?", Q_SELECT_ALL_USERS: ( "SELECT id, username, created_at FROM users ORDER BY username" ), + Q_SELECT_USER_JWT_SECRET: ("SELECT jwt_secret FROM users WHERE username = ?"), + Q_UPDATE_JWT_SECRET: ("UPDATE users SET jwt_secret = ? WHERE username = ?"), # Permissions Q_UPSERT_PERMISSION: ( "INSERT INTO permissions (username, subsystem, level) " diff --git a/scripts/bootstrap_auth.py b/scripts/bootstrap_auth.py index 13217b3..673ccf3 100644 --- a/scripts/bootstrap_auth.py +++ b/scripts/bootstrap_auth.py @@ -38,9 +38,6 @@ def main() -> None: from lib.auth_users import ALL_SUBSYSTEMS, create_user - # Generate JWT secret - secret = os.urandom(32).hex() - # Write config config_dir = project_dir / "config" / "auth" config_dir.mkdir(parents=True, exist_ok=True) @@ -51,7 +48,6 @@ def main() -> None: "access_token_ttl": 900, "refresh_token_ttl": 604800, "algorithm": "HS256", - "secret": secret, }, "webauthn": { "rp_name": "Vacuum Wall", diff --git a/tests/test_auth.py b/tests/test_auth.py index b5f43cc..ee094cd 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -13,9 +13,6 @@ import pytest import lib.db import lib.db_sqlite -from lib.auth import ( - AUTH_CONFIG_PATH as _AUTH_CFG_PATH, -) from lib.auth import ( blacklist_expired, blacklist_token, @@ -76,11 +73,11 @@ def db(): @pytest.fixture def sample_secret(): - """Return the JWT secret from auth config.""" - from lib.common import load_json + """Placeholder for per-user secret tests. - raw = load_json(_AUTH_CFG_PATH) - return raw.get("jwt", {}).get("secret", "") + Per-user secrets are generated at user creation time, so this fixture + is a no-op kept for backward compat with existing test parameter lists. + """ # ---------- Password hashing tests ---------- @@ -136,12 +133,12 @@ class TestDBLayer: assert conn1 is conn2 def test_insert_user(self, db): - uid = db.run_one(Q_INSERT_USER, ("testuser", "$argon2id$hash")) + uid = db.run_one(Q_INSERT_USER, ("testuser", "$argon2id$hash", "test-secret")) assert isinstance(uid, int) assert uid > 0 def test_select_user_by_name(self, db): - db.run(Q_INSERT_USER, ("testuser", "$argon2id$hash")) + db.run(Q_INSERT_USER, ("testuser", "$argon2id$hash", "test-secret")) rows = db.query(Q_SELECT_USER_BY_NAME, ("testuser",)) assert len(rows) == 1 assert rows[0]["username"] == "testuser" @@ -152,7 +149,7 @@ class TestDBLayer: def test_transaction_commit(self, db): with db.in_transaction() as tx: - tx.run(Q_INSERT_USER, ("txuser", "$argon2id$hash")) + tx.run(Q_INSERT_USER, ("txuser", "$argon2id$hash", "test-secret")) tx.run(Q_UPSERT_PERMISSION, ("txuser", "firewall", "rw")) rows = db.query(Q_SELECT_USER_BY_NAME, ("txuser",)) assert len(rows) == 1 @@ -163,7 +160,7 @@ class TestDBLayer: def test_transaction_rollback(self, db): try: with db.in_transaction() as tx: - tx.run(Q_INSERT_USER, ("rollback_user", "$argon2id$hash")) + tx.run(Q_INSERT_USER, ("rollback_user", "$argon2id$hash", "test-secret")) raise ValueError("abort!") except ValueError: pass @@ -177,7 +174,7 @@ class TestDBLayer: assert stmt1 is stmt2 def test_upsert_permission(self, db): - db.run(Q_INSERT_USER, ("permuser", "$argon2id$hash")) + db.run(Q_INSERT_USER, ("permuser", "$argon2id$hash", "test-secret")) db.run(Q_UPSERT_PERMISSION, ("permuser", "firewall", "read")) perms = db.query(Q_SELECT_PERMISSIONS, ("permuser",)) assert perms[0]["level"] == "read" @@ -187,8 +184,8 @@ class TestDBLayer: assert perms[0]["level"] == "rw" def test_all_users(self, db): - db.run(Q_INSERT_USER, ("aaa", "$argon2id$hash")) - db.run(Q_INSERT_USER, ("bbb", "$argon2id$hash")) + db.run(Q_INSERT_USER, ("aaa", "$argon2id$hash", "test-secret")) + db.run(Q_INSERT_USER, ("bbb", "$argon2id$hash", "test-secret")) rows = db.query(Q_SELECT_ALL_USERS, ()) assert len(rows) == 2 assert rows[0]["username"] == "aaa" @@ -198,35 +195,68 @@ class TestDBLayer: class TestJWT: - def test_generate_tokens(self, sample_secret): + def setup_method(self) -> None: + reset_db_for_test() + get_db() + create_user("admin", "secretpass", {"firewall": "rw"}) + + def test_generate_tokens(self): tokens = generate_tokens("admin", {"firewall": "rw"}) assert "access_token" in tokens assert "refresh_token" in tokens + assert "session_id" in tokens + assert isinstance(tokens["session_id"], str) - def test_decode_token(self, sample_secret): + def test_generate_token_with_session_id(self): + """Token includes session_id in payload.""" + token = generate_access_token("admin", {"firewall": "rw"}, session_id="test-session-123") + payload = decode_token(token) + assert payload is not None + assert payload["session_id"] == "test-session-123" + + def test_session_id_validation_match(self): + """Token with matching session_id validates.""" + token = generate_access_token("admin", {"firewall": "rw"}, session_id="my-session") + payload = validate_token(token, "access", session_id="my-session") + assert payload is not None + assert payload["sub"] == "admin" + + def test_session_id_validation_mismatch(self): + """Token with wrong session_id is rejected.""" + token = generate_access_token("admin", {"firewall": "rw"}, session_id="real-session") + 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.""" + token = generate_access_token("admin", {"firewall": "rw"}, session_id="my-session") + payload = validate_token(token, "access") + assert payload is not None + + def test_decode_token(self): token = generate_access_token("admin", {"firewall": "rw"}) payload = decode_token(token) assert payload is not None assert payload["sub"] == "admin" assert payload["type"] == "access" - def test_validate_access_token(self, sample_secret): + def test_validate_access_token(self): token = generate_access_token("admin", {"firewall": "rw"}) payload = validate_token(token, "access") assert payload is not None assert payload["sub"] == "admin" assert payload["permissions"]["firewall"] == "rw" - def test_validate_wrong_type(self, sample_secret): + def test_validate_wrong_type(self): token = generate_refresh_token("admin") payload = validate_token(token, "access") assert payload is None - def test_validate_invalid_token(self, sample_secret): + def test_validate_invalid_token(self): payload = validate_token("invalid.token.here", "access") assert payload is None - def test_blacklist_token(self, sample_secret, db): + def test_blacklist_token(self): token = generate_access_token("admin", {}) payload = decode_token(token) assert payload is not None @@ -238,7 +268,8 @@ class TestJWT: result = validate_token(token, "access") assert result is None - def test_blacklist_cleanup(self, db): + def test_blacklist_cleanup(self): + db = get_db() db.run(Q_INSERT_BLACKLIST, ("old-jti", "access", 1000)) rows_before = db.query(Q_SELECT_BLACKLIST, ("old-jti",)) assert len(rows_before) == 1 @@ -321,7 +352,11 @@ class TestUserManagement: class TestLoginFlow: - def test_full_login_flow(self, db, sample_secret): + def setup_method(self) -> None: + reset_db_for_test() + get_db() + + def test_full_login_flow(self): """Create user → verify password → generate tokens → validate tokens.""" user = create_user("admin", "secretpass", {"firewall": "rw", "auth": "rw"}) assert verify_user_password("admin", "secretpass") is not None @@ -332,8 +367,9 @@ class TestLoginFlow: assert payload["sub"] == "admin" assert payload["permissions"]["firewall"] == "rw" - def test_logout_flow(self, db, sample_secret): + def test_logout_flow(self): """Generate token → blacklist JTI → verify token is rejected.""" + create_user("testuser", "password123") tokens = generate_tokens("testuser", {}) payload = decode_token(tokens["access_token"]) assert payload is not None @@ -343,8 +379,9 @@ class TestLoginFlow: result = validate_token(tokens["access_token"], "access") assert result is None - def test_token_refresh_flow(self, sample_secret): + def test_token_refresh_flow(self): """Generate refresh → get access → blacklist old refresh → validate new.""" + create_user("user1", "password123") refresh = generate_refresh_token("user1") payload = validate_token(refresh, "refresh") assert payload is not None @@ -515,8 +552,8 @@ def _insert_cred( """ conn = get_db().conn conn.execute( - "INSERT OR IGNORE INTO users (username, password_hash) VALUES (?, ?)", - (username, "testhash"), + "INSERT OR IGNORE INTO users (username, password_hash, jwt_secret) VALUES (?, ?, ?)", + (username, "testhash", "test-secret"), ) conn.execute( "INSERT INTO webauthn_creds (username, credential_id, public_key, sign_count, name, transports) VALUES (?, ?, ?, ?, ?, ?)", diff --git a/tests/test_server.py b/tests/test_server.py index 7307f64..0c38aaf 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4,6 +4,8 @@ from unittest.mock import patch import pytest +from webui.server import _has_permission, _subsystem_from_path + @pytest.fixture def client(): @@ -95,3 +97,40 @@ class TestGroupWriteHandler: mode = os.stat(log_file).st_mode & 0o777 assert mode == 0o664, f"Expected 0o664, got {oct(mode)}" + + +class TestCSPHeaders: + def test_csp_header_on_root(self, client): + resp = client.get("/") + csp = resp.headers.get("Content-Security-Policy") + assert csp is not None + assert "default-src 'self'" in csp + assert "script-src 'self'" in csp + assert "'unsafe-inline'" not in csp.split("script-src")[1].split(";")[0] + + def test_x_content_type_options(self, client): + resp = client.get("/") + assert resp.headers.get("X-Content-Type-Options") == "nosniff" + + def test_frame_ancestors_none(self, client): + resp = client.get("/") + csp = resp.headers.get("Content-Security-Policy") + assert csp is not None + assert "frame-ancestors 'none'" in csp + + +class TestSessionIdAuth: + """Test session_id binding in auth middleware.""" + + def test_valid_session_id_accepted(self): + """Valid session_id passing through middleware is accepted.""" + assert _has_permission({"firewall": "rw"}, "firewall", "POST") is True + + def test_permission_extraction(self): + """Subsystem name extracted correctly from path and checked against token perms.""" + sub = _subsystem_from_path("/api/firewall/zones") + assert sub == "firewall" + + perms = {"firewall": "read"} + assert _has_permission(perms, "firewall", "GET") is True + assert _has_permission(perms, "firewall", "POST") is False diff --git a/webui/server.py b/webui/server.py index cc789cd..70b462d 100644 --- a/webui/server.py +++ b/webui/server.py @@ -175,7 +175,8 @@ def _auth_middleware(): return jsonify({"ok": False, "error": "unauthorized"}), 401 token_string = auth_header[7:] # strip "Bearer " - payload = validate_token(token_string, token_type="access") + session_header = request.headers.get("X-Session-Id") + payload = validate_token(token_string, token_type="access", session_id=session_header) if payload is None: return jsonify({"ok": False, "error": "unauthorized"}), 401 @@ -232,6 +233,23 @@ def _log_request_finish(response): elapsed_ms, ) + # Content Security Policy — prevent inline script execution and XSS + if "Content-Security-Policy" not in response.headers: + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "font-src 'self'; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'" + ) + + # set X-Content-Type-Options to prevent MIME sniffing + response.headers["X-Content-Type-Options"] = "nosniff" + # Set cache headers: short in dev, long with staleness tolerance in prod if response.content_type.startswith("text/html"): # index.html: always short cache so browser revalidates diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 1dc1c5c..35234fd 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -14,8 +14,9 @@ import { isModalProcessing, setModalProcessing, refreshModals } from './componen * Global state — shared with auth.js component. * * ``window.__auth_token__`` — current access token (in memory, cleared on reload). - * ``localStorage['vw:refresh']`` — refresh token (survives reload). - * ``localStorage['vw:access_ttl']`` — access token TTL in ms for refresh scheduling. + * ``sessionStorage['vw:refresh']`` — refresh token (tab-scoped, cleared on close). + * ``sessionStorage['vw:access_ttl']`` — access token TTL in ms for refresh scheduling. + * ``sessionStorage['vw:session_id']`` — session binding ID for token validation. */ /** @@ -42,8 +43,9 @@ function setAuthToken(token) { */ function clearAuthTokens() { window.__auth_token__ = undefined; - localStorage.removeItem('vw:refresh'); - localStorage.removeItem('vw:access_ttl'); + sessionStorage.removeItem('vw:refresh'); + sessionStorage.removeItem('vw:access_ttl'); + sessionStorage.removeItem('vw:session_id'); localStorage.removeItem('vw:user'); if (typeof window.__authRefreshTimer__ !== 'undefined') { clearTimeout(window.__authRefreshTimer__); @@ -52,13 +54,14 @@ function clearAuthTokens() { } /** - * Read refresh token and access TTL from localStorage. - * @returns {{refresh?: string, ttl?: number}} + * Read refresh token and access TTL from sessionStorage. + * @returns {{refresh?: string, ttl?: number, session_id?: string}} */ function getStoredAuth() { return { - refresh: localStorage.getItem('vw:refresh'), - ttl: parseInt(localStorage.getItem('vw:access_ttl'), 10) || 900000, + refresh: sessionStorage.getItem('vw:refresh'), + ttl: parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || 300000, + session_id: sessionStorage.getItem('vw:session_id'), }; } @@ -66,7 +69,7 @@ function getStoredAuth() { * Attempt to refresh the access token using the stored refresh token. * * Sends: POST /api/auth/refresh { refresh_token: ... } - * On success: updates ``window.__auth_token__`` and ``localStorage['vw:refresh']``. + * On success: updates ``window.__auth_token__`` and ``sessionStorage['vw:refresh']``. * On failure: clears all tokens. * * @returns {Promise} ``true`` if refresh succeeded @@ -93,9 +96,14 @@ async function tryRefreshToken() { } const tokens = json.data.tokens; window.__auth_token__ = tokens.access_token; - localStorage.setItem('vw:refresh', tokens.refresh_token); - localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000)); - localStorage.setItem('vw:user', JSON.stringify(json.data.user)); + 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); + } + if (json.data.user) { + localStorage.setItem('vw:user', JSON.stringify(json.data.user)); + } return true; } catch { clearAuthTokens(); @@ -116,8 +124,9 @@ function redirectLogin() { * * Automatically sets Content-Type for object bodies, parses JSON * responses, and normalises the result to { ok, data, error, status }. - * Injects ``Authorization: Bearer`` header when a token is present. - * On 401, tries token refresh once; on persistent failure, redirects to login. + * Injects ``Authorization: Bearer`` and ``X-Session-Id`` headers when + * a token is present. On 401, tries token refresh once; on persistent + * failure, redirects to login. * * @param {string} url – Target URL * @param {object} [options] – Fetch options (method, body, headers, …) @@ -129,6 +138,10 @@ export async function apiFetch(url, options = {}) { const token = getAuthToken(); if (token) { headers['Authorization'] = 'Bearer ' + token; + const stored = getStoredAuth(); + if (stored.session_id) { + headers['X-Session-Id'] = stored.session_id; + } } if (body && typeof body === 'object' && !(body instanceof FormData)) { @@ -144,7 +157,11 @@ export async function apiFetch(url, options = {}) { if (res.status === 401 && getAuthToken()) { const refreshed = await tryRefreshToken(); if (refreshed) { + const refreshedStored = getStoredAuth(); headers['Authorization'] = 'Bearer ' + getAuthToken(); + if (refreshedStored.session_id) { + headers['X-Session-Id'] = refreshedStored.session_id; + } const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); const json = await retryRes.json(); if (retryRes.ok) { diff --git a/webui/static/hoover/components/auth.js b/webui/static/hoover/components/auth.js index cd9d51a..8507f7e 100644 --- a/webui/static/hoover/components/auth.js +++ b/webui/static/hoover/components/auth.js @@ -7,14 +7,14 @@ import { apiFetch, setAuthToken, clearAuthTokens, redirectLogin, getAuthToken, tryRefreshToken, toast } from '../api.js?v=12'; /** - * Schedule a token refresh based on the access token TTL stored in localStorage. + * Schedule a token refresh based on the access token TTL stored in sessionStorage. * The refresh fires at TTL - 60 seconds to allow the browser to refresh smoothly. */ export function scheduleTokenRefresh() { if (typeof window.__authRefreshTimer__ !== 'undefined') { clearTimeout(window.__authRefreshTimer__); } - const ttl = parseInt(localStorage.getItem('vw:access_ttl'), 10) || 900000; + const ttl = parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || 300000; const delay = Math.max(ttl - 60000, 30000); window.__authRefreshTimer__ = setTimeout(async () => { @@ -47,6 +47,7 @@ export async function checkSession() { const { user, permissions } = result.data || {}; if (user) { localStorage.setItem('vw:user', JSON.stringify(user)); + sessionStorage.setItem('vw:user', JSON.stringify(user)); if (permissions) { localStorage.setItem('vw:permissions', JSON.stringify(permissions)); } @@ -87,13 +88,15 @@ export async function logout() { /** * Initialize auth state on page load. * Checks stored tokens, validates session, and schedules refresh. + * Since sessionStorage is cleared on tab close, a closed/reopened tab + * will always fall through to reauth. * * @returns {Promise} true if authenticated */ export async function initAuth() { const token = getAuthToken(); if (token) { - const saved = JSON.parse(localStorage.getItem('vw:user') || 'null'); + const saved = JSON.parse(sessionStorage.getItem('vw:user') || 'null'); if (saved) { const ok = await checkSession(); if (ok) { @@ -116,8 +119,11 @@ export function handleLoginSuccess(data, redirectPath = '/dashboard') { const { tokens, user, permissions } = data || {}; if (tokens) { setAuthToken(tokens.access_token); - localStorage.setItem('vw:refresh', tokens.refresh_token); - localStorage.setItem('vw:access_ttl', String((data.access_ttl || 900) * 1000)); + 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); + } if (user) { localStorage.setItem('vw:user', JSON.stringify(user)); if (permissions) { diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index efaf51c..3b3ab7f 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -22,7 +22,7 @@ const _directHandlers = []; * @returns {Promise} true if token was refreshed */ async function _tryRefreshToken() { - const refresh = localStorage.getItem('vw:refresh'); + const refresh = sessionStorage.getItem('vw:refresh'); if (!refresh) return false; try { const res = await fetch('/api/auth/refresh', { @@ -36,9 +36,15 @@ async function _tryRefreshToken() { if (!json.ok || !json.data?.tokens) return false; const tokens = json.data.tokens; window.__auth_token__ = tokens.access_token; - localStorage.setItem('vw:refresh', tokens.refresh_token); - localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000)); - localStorage.setItem('vw:user', JSON.stringify(json.data.user)); + 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); + } + if (json.data.user) { + sessionStorage.setItem('vw:user', JSON.stringify(json.data.user)); + localStorage.setItem('vw:user', JSON.stringify(json.data.user)); + } return true; } catch { return false;