fix(security): add username validation, constant-time login, default credential rejection, and tooling

This commit is contained in:
2026-02-18 19:11:14 +01:00
parent 1521005b62
commit f41b8fd8c2
8 changed files with 393 additions and 1 deletions

View File

@@ -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."
)

View File

@@ -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",

View File

@@ -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:

View File

@@ -0,0 +1,85 @@
"""Tests for security validators (username, password complexity)."""
import sys
import os
backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
import pytest
from pydantic import ValidationError
from app.schemas.user import UserCreate
# ── Username validation ──────────────────────────────────────────────
class TestUsernameValidation:
def test_valid_username(self):
u = UserCreate(username="john_doe", password="SecurePass123!@#")
assert u.username == "john_doe"
def test_valid_username_with_hyphens(self):
u = UserCreate(username="john-doe", password="SecurePass123!@#")
assert u.username == "john-doe"
def test_valid_username_numeric(self):
u = UserCreate(username="user123", password="SecurePass123!@#")
assert u.username == "user123"
def test_too_short_username(self):
with pytest.raises(ValidationError, match="3-50 characters"):
UserCreate(username="ab", password="SecurePass123!@#")
def test_username_with_spaces(self):
with pytest.raises(ValidationError, match="3-50 characters"):
UserCreate(username="john doe", password="SecurePass123!@#")
def test_username_with_special_chars(self):
with pytest.raises(ValidationError, match="3-50 characters"):
UserCreate(username="john@doe", password="SecurePass123!@#")
def test_reserved_username_admin(self):
with pytest.raises(ValidationError, match="reserved"):
UserCreate(username="admin", password="SecurePass123!@#")
def test_reserved_username_root(self):
with pytest.raises(ValidationError, match="reserved"):
UserCreate(username="root", password="SecurePass123!@#")
def test_reserved_username_case_insensitive(self):
with pytest.raises(ValidationError, match="reserved"):
UserCreate(username="ADMIN", password="SecurePass123!@#")
# ── Password validation ─────────────────────────────────────────────
class TestPasswordValidation:
def test_valid_strong_password(self):
u = UserCreate(username="testuser", password="SecurePass123!@#")
assert u.password == "SecurePass123!@#"
def test_too_short_password(self):
with pytest.raises(ValidationError, match="12 characters"):
UserCreate(username="testuser", password="Short1!")
def test_no_uppercase(self):
with pytest.raises(ValidationError, match="uppercase"):
UserCreate(username="testuser", password="securepass123!@#")
def test_no_lowercase(self):
with pytest.raises(ValidationError, match="lowercase"):
UserCreate(username="testuser", password="SECUREPASS123!@#")
def test_no_digit(self):
with pytest.raises(ValidationError, match="digit"):
UserCreate(username="testuser", password="SecurePassword!@#")
def test_no_special_char(self):
with pytest.raises(ValidationError, match="special"):
UserCreate(username="testuser", password="SecurePass12345")