fix(setup): confronto costante, blocco dopo l'init, status minimale (#9)

_check_password confrontava la password con !=, ora hmac.compare_digest.

Nuova guardia _require_not_initialised: se esiste già un admin,
reset-db, seed, manage-user, change-user-password e init-db rispondono
403 "setup already completed", a meno di settings.setup_allow_after_init
(nuovo, env SETUP_ALLOW_AFTER_INIT, default False). create-admin,
list-users e toggle-user-active restano fuori dalla guardia.

GET /api/setup/status senza password valida restituisce solo
{"initialised": bool}; l'elenco tabelle solo con la password.

Adeguati i test esistenti che seedavano un admin e poi richiamavano un
endpoint ora guardato (test_setup.py, test_station_seed.py: bypassano
la guardia con setup_allow_after_init quando serve testare altro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:38:09 +02:00
parent 9afe1e712b
commit 78970c69b0
4 changed files with 237 additions and 6 deletions
+63 -3
View File
@@ -3,6 +3,7 @@
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
NOT protected by API Key auth (standalone access).
"""
import hmac
import random
import secrets
from datetime import datetime, timedelta
@@ -87,13 +88,57 @@ def _check_setup_enabled() -> None:
def _check_password(password: str) -> None:
"""Verify the provided password matches the setup password."""
_check_setup_enabled()
if password != settings.setup_password:
if not hmac.compare_digest(password, settings.setup_password):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid setup password",
)
def _password_matches(password: str | None) -> bool:
"""Constant-time password check that tolerates a missing password."""
if not password or not settings.setup_password:
return False
return hmac.compare_digest(password, settings.setup_password)
async def _admin_exists() -> bool:
"""Whether at least one admin user has been created.
False on a database with no `users` table yet, so init-db and the first
seed are never blocked by a query against a table that does not exist.
"""
async with engine.connect() as conn:
has_users = await conn.run_sync(
lambda sync_conn: sa_inspect(sync_conn).has_table("users")
)
if not has_users:
return False
async with async_session_factory() as session:
result = await session.execute(select(User).where(User.is_admin == True)) # noqa: E712
return result.scalars().first() is not None
async def _require_not_initialised() -> None:
"""Refuse privileged/destructive setup endpoints once an admin exists.
reset-db, seed, manage-user, change-user-password and init-db all reach
into a live database once setup is done: reset-db wipes it, seed adds
demo users and data, manage-user/change-user-password can take over an
account, init-db races Alembic. All of them are meant for the one-time
bring-up before there is an admin to log in as.
`settings.setup_allow_after_init` is the explicit override for scripted
re-provisioning of a demo/test deploy.
"""
if settings.setup_allow_after_init:
return
if await _admin_exists():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="setup already completed",
)
def _generate_measurements_for_subtask(
subtask: RecipeSubtask,
version_id: int,
@@ -220,14 +265,24 @@ async def setup_page(request: Request):
@router.get("/status")
async def setup_status():
"""Return database table status (no password required, but setup must be enabled)."""
async def setup_status(password: str | None = None):
"""Database status. Without a valid password, only whether setup is done.
The table listing is only returned with the setup password: it is
internal detail, not something to hand to whoever happens to load the
page.
"""
_check_setup_enabled()
initialised = await _admin_exists()
if not _password_matches(password):
return {"initialised": initialised}
async with engine.connect() as conn:
table_names: list[str] = await conn.run_sync(
lambda sync_conn: sa_inspect(sync_conn).get_table_names()
)
return {
"initialised": initialised,
"tables_exist": len(table_names) > 0,
"table_count": len(table_names),
"tables": table_names,
@@ -238,6 +293,7 @@ async def setup_status():
async def init_db(body: PasswordBody):
"""Create all database tables."""
_check_password(body.password)
await _require_not_initialised()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return {"status": "ok", "message": "Database tables created successfully"}
@@ -274,6 +330,7 @@ async def create_admin(body: CreateAdminBody):
async def seed_demo_data(body: PasswordBody):
"""Seed database with demo users and a sample recipe."""
_check_password(body.password)
await _require_not_initialised()
async with async_session_factory() as session:
async with session.begin():
@@ -544,6 +601,7 @@ async def list_users(body: PasswordBody):
async def manage_user(body: ManageUserBody):
"""Create or update a user. user_id=null creates new, user_id=int updates."""
_check_password(body.password)
await _require_not_initialised()
invalid = set(body.roles) - VALID_ROLES
if invalid:
@@ -618,6 +676,7 @@ async def manage_user(body: ManageUserBody):
async def change_user_password(body: ChangeUserPasswordBody):
"""Change a user's password and invalidate their API key."""
_check_password(body.password)
await _require_not_initialised()
async with async_session_factory() as session:
async with session.begin():
@@ -664,6 +723,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
async def reset_db(body: ResetDbBody):
"""Drop all tables and recreate them. Requires confirm=true."""
_check_password(body.password)
await _require_not_initialised()
if not body.confirm:
raise HTTPException(