From 6ace796c8bbe0cf3ef3ff53a220b4d9af9bfa419 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 9 Mar 2026 00:13:01 +0100 Subject: [PATCH] security: fix C2, H5 and M1 C2 - JWT token was stored in localStorage (XSS-accessible): - Switch Zustand persist storage from localStorage to sessionStorage - Token is now scoped to the current tab and cleared on browser close H5 - docker-compose had unsafe SECRET_KEY fallback: - Replace ${SECRET_KEY:-change_me_in_production} with :? syntax - Docker Compose now aborts with a clear error if SECRET_KEY is unset M1 - Login endpoint had timing leak allowing username enumeration: - Always call verify_password() regardless of username match - Use hmac.compare_digest() for constant-time username comparison - Both checks run every time; attacker cannot distinguish wrong username from wrong password via response timing --- backend/app/api/routes/auth.py | 8 +++++++- docker-compose.yml | 2 +- frontend/src/stores/authStore.ts | 9 +++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 59f0e68..b815847 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,3 +1,5 @@ +import hmac + import yaml from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel @@ -28,7 +30,11 @@ def _load_credentials() -> tuple[str, str]: @router.post("/login", response_model=TokenResponse) async def login(body: LoginRequest) -> TokenResponse: username, password_hash = _load_credentials() - if body.username != username or not verify_password(body.password, password_hash): + # Always run both checks to prevent timing-based username enumeration. + # hmac.compare_digest is constant-time; verify_password (bcrypt) always runs. + username_ok = hmac.compare_digest(body.username, username) + password_ok = verify_password(body.password, password_hash) + if not username_ok or not password_ok: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") token = create_access_token(body.username) return TokenResponse(access_token=token) diff --git a/docker-compose.yml b/docker-compose.yml index 6cf7573..22598c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: dockerfile: Dockerfile.backend restart: unless-stopped environment: - SECRET_KEY: ${SECRET_KEY:-change_me_in_production} + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set in the environment or a .env file} SQLITE_PATH: /app/data/homelab.db CONFIG_PATH: /app/config.yml CORS_ORIGINS: '["http://localhost:3000"]' diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 84933d1..889ff88 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import { persist } from 'zustand/middleware' +import { persist, createJSONStorage } from 'zustand/middleware' interface AuthState { token: string | null @@ -16,6 +16,11 @@ export const useAuthStore = create()( login: (token) => set({ token, isAuthenticated: true }), logout: () => set({ token: null, isAuthenticated: false }), }), - { name: 'homelable-auth' } + { + name: 'homelable-auth', + // sessionStorage: scoped to the tab, cleared on browser close. + // Prevents XSS from other tabs stealing the token via localStorage. + storage: createJSONStorage(() => sessionStorage), + } ) )