fix: comprehensive security hardening from 7-agent review

Schema validation:
- Add max_length to init_data (4096), widget fields (first_name 64,
  last_name 64, username 32, photo_url 512, hash 64)
- Add max_length=2048 to all token fields (verify, refresh, reset, auto-login)
- Add max_length=128 to EmailLoginRequest.password (bcrypt DoS prevention)
- Add pattern to OAuthCallbackRequest.referral_code (was missing)
- Add pattern=r'^\d{6}$' to EmailChangeVerifyRequest.code
- Add pattern/max_length to language field (ISO 639-1)

Auth endpoints:
- Add user.status check to auto-login (banned users could authenticate)
- Add exception chaining (from e) to refresh and auto-login endpoints
- Add IP rate limiting to initData, register standalone, verify email,
  forgot password, and reset password endpoints

CORS:
- Add PATCH to allow_methods in both unified_app and webapi (38+ PATCH
  endpoints were blocked for cross-origin requests)

JWKS:
- Fix race condition: move force-refresh cooldown check and cache
  invalidation inside asyncio.Lock via dedicated _force_refresh_jwks()
This commit is contained in:
Fringg
2026-03-07 03:47:58 +03:00
parent 5499ad62dc
commit e96fe1ecd8
6 changed files with 91 additions and 34 deletions

View File

@@ -161,18 +161,18 @@ def _build_public_keys(jwks_data: dict[str, Any]) -> dict[str, Any]:
return public_keys
async def _get_jwks() -> dict[str, Any]:
async def _get_jwks(force: bool = False) -> dict[str, Any]:
"""Fetch and cache Telegram OIDC JWKS keys."""
global _jwks_cache, _jwks_cache_expiry
now = datetime.now(UTC)
if _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
return _jwks_cache
async with _jwks_lock:
# Double-check after acquiring lock
now = datetime.now(UTC)
if _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
return _jwks_cache
async with httpx.AsyncClient(timeout=10) as client:
@@ -183,6 +183,21 @@ async def _get_jwks() -> dict[str, Any]:
return _jwks_cache
async def _force_refresh_jwks(kid: str) -> dict[str, Any] | None:
"""Force JWKS refresh with cooldown protection. Returns refreshed JWKS or None if on cooldown."""
global _jwks_cache_expiry, _jwks_last_force_refresh
async with _jwks_lock:
now = datetime.now(UTC)
if _jwks_last_force_refresh and (now - _jwks_last_force_refresh).total_seconds() < _JWKS_FORCE_REFRESH_COOLDOWN_SECONDS:
logger.warning('Telegram OIDC: JWKS force refresh on cooldown', kid=kid)
return None
_jwks_last_force_refresh = now
_jwks_cache_expiry = None
return await _get_jwks(force=True)
async def validate_telegram_oidc_token(id_token: str, client_id: str) -> dict[str, Any] | None:
"""
Validate a Telegram OIDC id_token using JWKS.
@@ -206,15 +221,9 @@ async def validate_telegram_oidc_token(id_token: str, client_id: str) -> dict[st
# If kid not found, force JWKS refresh (key rotation) with cooldown
if kid and kid not in public_keys:
global _jwks_cache_expiry, _jwks_last_force_refresh
now = datetime.now(UTC)
if _jwks_last_force_refresh and (now - _jwks_last_force_refresh).total_seconds() < _JWKS_FORCE_REFRESH_COOLDOWN_SECONDS:
logger.warning('Telegram OIDC: JWKS force refresh on cooldown', kid=kid)
else:
_jwks_last_force_refresh = now
_jwks_cache_expiry = None
jwks_data = await _get_jwks()
public_keys = _build_public_keys(jwks_data)
refreshed = await _force_refresh_jwks(kid)
if refreshed:
public_keys = _build_public_keys(refreshed)
if not kid or kid not in public_keys:
logger.warning('Telegram OIDC: unknown kid in id_token', kid=kid)

View File

@@ -374,6 +374,7 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
@router.post('/telegram', response_model=AuthResponse)
async def auth_telegram(
request: TelegramAuthRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -382,6 +383,13 @@ async def auth_telegram(
This endpoint validates the initData from Telegram WebApp and returns
JWT tokens for authenticated access.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'telegram_initdata', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
user_data = validate_telegram_init_data(request.init_data)
if not user_data:
@@ -782,6 +790,7 @@ async def register_email(
@router.post('/email/register/standalone', response_model=RegisterResponse)
async def register_email_standalone(
request: EmailRegisterStandaloneRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -794,6 +803,13 @@ async def register_email_standalone(
If TEST_EMAIL is configured, test email accounts are auto-verified.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'email_register', limit=5, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# Check if this is a test email registration
is_test_email = settings.is_test_email(request.email)
@@ -924,9 +940,17 @@ async def register_email_standalone(
@router.post('/email/verify', response_model=AuthResponse)
async def verify_email(
request: EmailVerifyRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Verify email with token and return auth tokens."""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'email_verify', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# Find user with this token
result = await db.execute(select(User).where(User.email_verification_token == request.token))
user = result.scalar_one_or_none()
@@ -1139,11 +1163,11 @@ async def refresh_token(
try:
user_id = int(payload.get('sub'))
except (TypeError, ValueError):
except (TypeError, ValueError) as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid token payload',
)
) from e
# Verify token exists in database and is not revoked
token_hash = hashlib.sha256(request.refresh_token.encode()).hexdigest()
@@ -1239,11 +1263,11 @@ async def auto_login(
try:
user_id = int(payload['sub'])
except (KeyError, ValueError, TypeError):
except (KeyError, ValueError, TypeError) as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid token payload',
)
) from e
user = await get_user_by_id(db, user_id)
if not user:
@@ -1252,6 +1276,12 @@ async def auto_login(
detail='User not found',
)
if user.status != 'active':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is deactivated',
)
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
user.cabinet_last_login = datetime.now(UTC)
@@ -1263,9 +1293,17 @@ async def auto_login(
@router.post('/password/forgot')
async def forgot_password(
request: PasswordForgotRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Request password reset."""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'password_forgot', limit=3, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
result = await db.execute(select(User).where(User.email == request.email))
user = result.scalar_one_or_none()
@@ -1324,9 +1362,17 @@ async def forgot_password(
@router.post('/password/reset')
async def reset_password(
request: PasswordResetRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset password with token."""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'password_reset', limit=5, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
result = await db.execute(select(User).where(User.password_reset_token == request.token))
user = result.scalar_one_or_none()

View File

@@ -82,7 +82,9 @@ class OAuthCallbackRequest(BaseModel):
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
# --- Endpoints ---

View File

@@ -8,7 +8,7 @@ from pydantic import BaseModel, EmailStr, Field
class TelegramAuthRequest(BaseModel):
"""Request for Telegram WebApp initData authentication."""
init_data: str = Field(..., description='Telegram WebApp initData string')
init_data: str = Field(..., max_length=4096, description='Telegram WebApp initData string')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
@@ -21,12 +21,12 @@ class TelegramWidgetAuthRequest(BaseModel):
"""Request for Telegram Login Widget authentication."""
id: int = Field(..., description='Telegram user ID')
first_name: str = Field(..., description="User's first name")
last_name: str | None = Field(None, description="User's last name")
username: str | None = Field(None, description="User's username")
photo_url: str | None = Field(None, description="User's photo URL")
first_name: str = Field(..., max_length=64, description="User's first name")
last_name: str | None = Field(None, max_length=64, description="User's last name")
username: str | None = Field(None, max_length=32, description="User's username")
photo_url: str | None = Field(None, max_length=512, description="User's photo URL")
auth_date: int = Field(..., description='Unix timestamp of authentication')
hash: str = Field(..., description='Authentication hash')
hash: str = Field(..., min_length=64, max_length=64, description='Authentication hash')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
@@ -57,7 +57,7 @@ class EmailRegisterRequest(BaseModel):
class EmailVerifyRequest(BaseModel):
"""Request to verify email with token."""
token: str = Field(..., description='Email verification token')
token: str = Field(..., max_length=2048, description='Email verification token')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
@@ -67,7 +67,7 @@ class EmailLoginRequest(BaseModel):
"""Request to login with email and password."""
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., description='Password')
password: str = Field(..., min_length=1, max_length=128, description='Password')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
@@ -76,7 +76,7 @@ class EmailLoginRequest(BaseModel):
class RefreshTokenRequest(BaseModel):
"""Request to refresh access token."""
refresh_token: str = Field(..., description='Refresh token')
refresh_token: str = Field(..., max_length=2048, description='Refresh token')
class PasswordForgotRequest(BaseModel):
@@ -88,14 +88,14 @@ class PasswordForgotRequest(BaseModel):
class PasswordResetRequest(BaseModel):
"""Request to reset password with token."""
token: str = Field(..., description='Password reset token')
token: str = Field(..., max_length=2048, description='Password reset token')
password: str = Field(..., min_length=8, max_length=128, description='New password (min 8 chars)')
class AutoLoginRequest(BaseModel):
"""Request for auto-login from guest purchase success page."""
token: str = Field(..., description='Auto-login JWT token')
token: str = Field(..., max_length=2048, description='Auto-login JWT token')
class TokenResponse(BaseModel):
@@ -134,7 +134,7 @@ class EmailRegisterStandaloneRequest(BaseModel):
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., min_length=8, max_length=128, description='Password (min 8 chars)')
first_name: str | None = Field(None, max_length=64, description='First name')
language: str = Field('ru', description='Preferred language')
language: str = Field('ru', max_length=5, pattern=r'^[a-z]{2}$', description='Preferred language (ISO 639-1)')
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
@@ -178,7 +178,7 @@ class EmailChangeRequest(BaseModel):
class EmailChangeVerifyRequest(BaseModel):
"""Request to verify email change with code."""
code: str = Field(..., min_length=6, max_length=6, description='6-digit verification code')
code: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$', description='6-digit verification code')
class EmailChangeResponse(BaseModel):

View File

@@ -200,7 +200,7 @@ def create_web_api_app() -> FastAPI:
CORSMiddleware,
allow_origins=['*'],
allow_credentials=False,
allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allow_methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allow_headers=['Authorization', 'Content-Type'],
)
else:
@@ -208,7 +208,7 @@ def create_web_api_app() -> FastAPI:
CORSMiddleware,
allow_origins=all_origins,
allow_credentials=True,
allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allow_methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allow_headers=['Authorization', 'Content-Type'],
)

View File

@@ -72,7 +72,7 @@ def _create_base_app() -> FastAPI:
CORSMiddleware,
allow_origins=['*'],
allow_credentials=False,
allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allow_methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allow_headers=['Authorization', 'Content-Type'],
)
else:
@@ -80,7 +80,7 @@ def _create_base_app() -> FastAPI:
CORSMiddleware,
allow_origins=cabinet_origins,
allow_credentials=True,
allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allow_methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allow_headers=['Authorization', 'Content-Type'],
)
app.include_router(cabinet_router)