harden(admin): cap scrypt work factor, keep generated password off disk

Address review hardening items on the secret-storage path:

- vault.verify_password caps N/r/p at the parameters this module emits, so a
  tampered or corrupt stored hash can't force an unbounded scrypt work factor
  (memory grows with N*r) and turn a login into an OOM/DoS.
- bootstrap prints the generated first-run admin password to stdout instead of
  the logger, so it reaches the operator once without being persisted into the
  on-disk log files.
- admin_login reads the password hash while the DB session is open rather than
  off a detached ORM instance after the context exits.
- drop a stray debug print of the request payload in upsert_provider_model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jeroen Ubbink
2026-07-23 10:51:20 +02:00
co-authored by Claude Opus 4.8
parent 4872c318d5
commit b8700dde40
3 changed files with 21 additions and 14 deletions
+5 -3
View File
@@ -343,11 +343,14 @@ async def admin_login(
) -> dict[str, object]:
async with create_session() as session:
secret = await get_secret(session)
# Read the hash while the session is open; the ORM object is detached
# once the context exits and its attributes can no longer be loaded.
password_hash = secret.admin_password_hash
if not secret.admin_password_hash:
if not password_hash:
raise HTTPException(status_code=500, detail="Admin password not configured")
if not vault.verify_password(payload.password, secret.admin_password_hash):
if not vault.verify_password(payload.password, password_hash):
raise HTTPException(status_code=401, detail="Invalid password")
token = secrets.token_urlsafe(32)
@@ -526,7 +529,6 @@ class ModelCreate(BaseModel):
async def upsert_provider_model(
provider_id: str, payload: ModelCreate
) -> dict[str, object]:
print(payload)
logger.info(
f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}"
)
+7 -8
View File
@@ -467,9 +467,6 @@ async def bootstrap_secrets(db_session: AsyncSession) -> None:
from . import vault
from .db import get_secret
from .logging import get_logger
logger = get_logger(__name__)
raw_blob = await _read_raw_settings_blob(db_session)
secret = await get_secret(db_session)
@@ -486,12 +483,14 @@ async def bootstrap_secrets(db_session: AsyncSession) -> None:
generated = secrets.token_urlsafe(24)
secret.admin_password_hash = vault.hash_password(generated)
admin_url = (settings.http_url or "http://localhost:8000").rstrip("/")
logger.warning(
# Print to stdout rather than the logger: the operator must see this
# once (e.g. `docker compose logs`), but it must not be persisted
# into the on-disk log files the logger also writes to.
print(
"No admin password set; generated a temporary one (shown only "
"now): %s\nLog in at %s/admin and change it from the dashboard "
"settings.",
generated,
admin_url,
f"now): {generated}\nLog in at {admin_url}/admin and change it "
"from the dashboard settings.",
flush=True,
)
changed = True
+9 -3
View File
@@ -116,14 +116,20 @@ def verify_password(password: str, stored: str) -> bool:
scheme, n, r, p, salt_b64, hash_b64 = stored.split(":")
if scheme != "scrypt":
return False
n_int, r_int, p_int = int(n), int(r), int(p)
# Cap the work factor at the parameters this module emits. scrypt's
# memory cost grows with N*r, so an oversized N/r in a tampered or
# corrupt stored hash could turn a single login into an OOM/DoS.
if n_int > _SCRYPT_N or r_int > _SCRYPT_R or p_int > _SCRYPT_P:
return False
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64)
derived = hashlib.scrypt(
password.encode(),
salt=salt,
n=int(n),
r=int(r),
p=int(p),
n=n_int,
r=r_int,
p=p_int,
dklen=len(expected),
)
except (ValueError, TypeError):