from datetime import datetime, timedelta, timezone import hashlib import hmac from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network from typing import Protocol from fastapi import HTTPException, Request from sqlalchemy import delete, func, select, text from sqlalchemy.orm import Session from .models import SubmissionAttempt class RateLimitConfig(Protocol): rate_limit_secret: str trusted_proxy_cidrs: list[str] def is_trusted_proxy(address: str, trusted_cidrs: list[str]) -> bool: try: addr = IPv4Address(address) if ":" not in address else IPv6Address(address) except ValueError: return False for cidr in trusted_cidrs: try: network = IPv4Network(cidr) if ":" not in cidr else IPv6Network(cidr) if addr in network: return True except ValueError: continue return False def check_rate_limit(request: Request, db: Session, config: RateLimitConfig) -> None: now = datetime.now(timezone.utc) cutoff = now - timedelta(minutes=10) client = request.client.host if request.client else "unknown" forwarded = request.headers.get("x-forwarded-for") if forwarded and request.client and is_trusted_proxy(request.client.host, config.trusted_proxy_cidrs): client = forwarded.split(",")[0].strip() client_hash = hmac.new(config.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest() if db.get_bind().dialect.name == "postgresql": lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": lock_key}) db.execute(delete(SubmissionAttempt).where(SubmissionAttempt.created_at < now - timedelta(days=1))) recent = db.scalar( select(func.count()).select_from(SubmissionAttempt).where( SubmissionAttempt.client_hash == client_hash, SubmissionAttempt.created_at >= cutoff, ) ) or 0 if recent >= 5: db.commit() raise HTTPException(status_code=429, detail="too many submissions") db.add(SubmissionAttempt(client_hash=client_hash, created_at=now)) db.commit()