Equalize signin bcrypt cost to mitigate user-enumeration timing (CWE-208)

authenticate_user skipped bcrypt entirely when the email did not resolve
to a user (or had no active credential), so a non-existent account
returned in ~5ms while a valid one paid the full bcrypt cost (~150ms),
letting an attacker enumerate accounts by response time.

Run the bound verify_password against a fixed dummy hash on the no-user
and no-credential paths so every signin attempt pays the same bcrypt
cost. This collapses the dominant, trivially-exploitable gap (measured
~1.03x vs the prior ~20-40x). A residual sub-millisecond variance
(DB index hit/miss, interpreter/GC, CPU and OS scheduling) is not
eliminable in application code and is accepted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Classic298 2026-06-11 11:00:19 +02:00
parent b1d40f3409
commit 4ec8a43735

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import bcrypt
import logging
import uuid
from typing import Optional
@ -15,6 +16,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
# Equalizes bcrypt cost on the no-user / no-credential paths so signin timing does not reveal account existence (CWE-208).
_TIMING_EQUALIZER_HASH = bcrypt.hashpw(b'open-webui-timing-equalizer', bcrypt.gensalt()).decode('utf-8')
class Auth(Base): # credential ↔ user linkage
"""Maps a user ID to an email/password pair with an active flag."""
@ -142,11 +146,13 @@ class AuthsTable:
log.info('authenticate_user: %s', email)
resolved = await Users.get_user_by_email(email, db=db)
if not resolved:
verify_password(_TIMING_EQUALIZER_HASH) # pay the bcrypt cost so response time doesn't reveal account existence
return
# load the credential row and verify the password hash
async with get_async_db_context(db) as session:
credential = await session.get(Auth, resolved.id)
if not credential or not credential.active:
verify_password(_TIMING_EQUALIZER_HASH) # equalize cost on this path too
return
if not verify_password(credential.password):
return