fix(security): add username validation, constant-time login, default credential rejection, and tooling
This commit is contained in:
@@ -106,3 +106,19 @@ if settings.SECRET_KEY in _UNSAFE_SECRETS:
|
||||
"Set SECRET_KEY in your environment for persistent sessions.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SEC-002: Reject default credentials in production
|
||||
# ---------------------------------------------------------------------------
|
||||
if _is_production:
|
||||
_DEFAULT_CREDS = {
|
||||
("MINIO_ACCESS_KEY", settings.MINIO_ACCESS_KEY, "minioadmin"),
|
||||
("MINIO_SECRET_KEY", settings.MINIO_SECRET_KEY, "minioadmin"),
|
||||
}
|
||||
for name, current, default in _DEFAULT_CREDS:
|
||||
if current == default:
|
||||
raise RuntimeError(
|
||||
f"CRITICAL: {name} is using the default value '{default}'. "
|
||||
f"Set a strong value via the {name} environment variable "
|
||||
f"before running in production."
|
||||
)
|
||||
|
||||
@@ -58,7 +58,13 @@ def login(
|
||||
"""
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
|
||||
if user is None or not verify_password(form_data.password, user.hashed_password):
|
||||
# Constant-time comparison: always run bcrypt verify to prevent
|
||||
# timing-based user enumeration (SEC-005).
|
||||
_DUMMY_HASH = "$2b$12$LJ3m4ys3Lg3dMO/NpNmOaeVwFpWJMxlB2FLmEAo9fZr.S8H1vC4Wy"
|
||||
hashed = user.hashed_password if user else _DUMMY_HASH
|
||||
password_valid = verify_password(form_data.password, hashed)
|
||||
|
||||
if user is None or not password_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect username or password",
|
||||
|
||||
@@ -7,6 +7,27 @@ from datetime import datetime
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
|
||||
|
||||
|
||||
# ── Username policy ─────────────────────────────────────────────────
|
||||
|
||||
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{3,50}$")
|
||||
_RESERVED_USERNAMES = frozenset({
|
||||
"admin", "root", "system", "api", "null", "undefined",
|
||||
"administrator", "superuser", "aegis",
|
||||
})
|
||||
|
||||
|
||||
def _validate_username(username: str) -> str:
|
||||
"""Validate username format and reject reserved names."""
|
||||
if not _USERNAME_RE.match(username):
|
||||
raise ValueError(
|
||||
"Username must be 3-50 characters, containing only "
|
||||
"letters, digits, underscores, and hyphens"
|
||||
)
|
||||
if username.lower() in _RESERVED_USERNAMES:
|
||||
raise ValueError(f"Username '{username}' is reserved")
|
||||
return username
|
||||
|
||||
|
||||
# ── Password policy ─────────────────────────────────────────────────
|
||||
|
||||
_MIN_PASSWORD_LENGTH = 12
|
||||
@@ -56,6 +77,11 @@ class UserCreate(BaseModel):
|
||||
password: str
|
||||
role: str = "viewer"
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def username_format(cls, v: str) -> str:
|
||||
return _validate_username(v)
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_strength(cls, v: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user