Files
MathApp/config/middleware.py
Zareian e3bddb3757 Trust X-Forwarded-For only from ArvanCloud edges
nginx on the server also answers on its own IP. A direct request with a fresh
X-Forwarded-For each time got a new throttle identity every time and skipped
every per-IP limit (OTP request/login, admin login): reproduced on the server
2026-10-13, 65 of 65 health requests with rotating XFF got 200.

TrustedProxyMiddleware reads X-Real-IP (set by nginx from the TCP peer) and,
when the peer is not in TRUSTED_PROXY_NETWORKS (default: ArvanCloud's
published list), replaces XFF with the peer address. Logs a WARNING once an
hour per untrusted peer that sent a proxied XFF, so a new Arvan range shows up.

Also ignore the parallel test DB clones (.test-db_N.sqlite3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:45:27 +03:30

225 lines
12 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
میان‌افزارِ سبکِ لاگِ درخواست‌های API — سطحِ پروژه.
⚠️ افزوده شده: پروژه چند logger پراکنده‌ی اپلیکیشنی داشت (accounts.services،
ai_tutor.views، ...) اما هیچ لاگِ یکپارچه‌ای در سطحِ پروژه برای *همه‌ی*
درخواست‌های API وجود نداشت؛ یعنی درخواست‌های عادی، ناموفق، یا الگوهای
مشکوک (مثلاً یک IP/کاربر که پشتِ سرِ هم درخواستِ ناموفق می‌فرسته) به‌صورتِ
یکدست قابلِ ردیابی نبودن. این میان‌افزار فقط یک خط لاگِ خلاصه به‌ازای هر
درخواستِ زیرِ /api/ ثبت می‌کنه: متد، مسیر، کدِ وضعیتِ پاسخ، مدت‌زمان، و
شناسه‌ی کاربرِ احرازهویت‌شده (اگه وجود داشته باشه).
نکاتِ امنیتی/حریمِ خصوصی:
- هیچ‌وقت بدنه‌ی درخواست/پاسخ، query string، هدرها (از جمله Authorization،
Idempotency-Key و غیره)، رمزعبور/OTP، access/refresh token، کلیدِ API، یا
سکرتِ webhook لاگ نمی‌شه — فقط request.path (بدونِ query string) و
متادیتای بی‌ضررِ بالا.
- در صورتِ هر خطای غیرمنتظره در خودِ فرآیندِ لاگ‌کردن (مثلاً یک مقدارِ
غیرمنتظره برای user)، این میان‌افزار هیچ‌وقت نباید جریانِ درخواست/پاسخ
را بشکنه؛ به همین خاطر کل بخشِ لاگ داخلِ try/except قرار گرفته.
- ⚠️ اصلاح: قبلاً اگه self.get_response(request) خودش یک exceptionِ
دست‌نخورده (unhandled) بالا می‌آورد، اون exception قبل از رسیدن به بلوکِ
لاگ از __call__ خارج می‌شد؛ یعنی خطاهای ۵۰۰ اصلاً لاگ نمی‌شدن. حالا
self.get_response در try/except قرار گرفته: در صورتِ exception، یک خطِ
لاگِ سطحِ ERROR (با متد/مسیر/مدت/user_id، بدونِ جزئیاتِ حساس) ثبت می‌شه
و بعد همون exceptionِ اصلی بدونِ تغییر دوباره raise می‌شه (تا رفتارِ
مدیریتِ خطای Django/DRF دست‌نخورده بمونه).
- ⚠️ اصلاحِ این دور: پیامِ لاگِ مسیرِ exception از یک placeholderِ واقعیِ
status=%s استفاده می‌کنه (هم‌سو با مسیرِ موفق)، و status=500 حالا از
طریقِ آرگومانِ status به _log_safely() صریحاً پاس داده می‌شه، نه به‌صورتِ
متنِ ثابت داخلِ رشته. این هم پیام رو با مسیرِ موفق یکدست می‌کنه و هم از
ناهماهنگیِ تعدادِ آرگومان‌های % formatting جلوگیری می‌کنه.
"""
import logging
import time
logger = logging.getLogger(__name__)
# فقط مسیرهای API لاگ می‌شن؛ درخواست‌های استاتیک/غیرِ API (که این پروژه
# اصلاً مسیرِ URL جداگانه‌ای براشون در config/urls.py تعریف نکرده) عمداً
# نادیده گرفته می‌شن تا لاگ شلوغ/بی‌فایده تولید نشه.
_LOGGED_PATH_PREFIX = '/api/'
def _user_id_for_log(request):
"""شناسه‌ی کاربرِ احرازهویت‌شده برای لاگ، یا None (کاربرِ ناشناس/بدونِ user)."""
user = getattr(request, 'user', None)
return user.id if getattr(user, 'is_authenticated', False) else None
class APIRequestLoggingMiddleware:
"""میان‌افزارِ Django برای لاگِ خلاصه‌ی هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not request.path.startswith(_LOGGED_PATH_PREFIX):
return self.get_response(request)
start = time.monotonic()
try:
response = self.get_response(request)
except Exception:
self._log_safely(
logger.error,
'api_request method=%s path=%s status=%s duration_ms=%.1f user_id=%s (unhandled exception)',
request, start, status=500,
)
raise
self._log_safely(
logger.info,
'api_request method=%s path=%s status=%s duration_ms=%.1f user_id=%s',
request, start, response.status_code,
)
return response
def _log_safely(self, log_func, message, request, start, status=None):
# لاگ‌کردن هیچ‌وقت نباید خودش دلیلِ شکستِ یک درخواست بشه (چه در
# مسیرِ موفق، چه در مسیرِ exception)؛ به همین خاطر کل بخشِ لاگ
# داخلِ try/except قرار گرفته.
try:
duration_ms = (time.monotonic() - start) * 1000
if status is None:
log_func(message, request.method, request.path, duration_ms, _user_id_for_log(request))
else:
log_func(message, request.method, request.path, status, duration_ms, _user_id_for_log(request))
except Exception:
logger.exception('api_request logging failed')
class TrustedProxyMiddleware:
"""
X-Forwarded-For فقط وقتی پذیرفته می‌شود که درخواست واقعاً از ArvanCloud آمده باشد.
NUM_PROXIES=2 فرض می‌کند همیشه آروان و nginx جلوی ما هستند؛ ولی nginxِ این ماشین روی IPِ خودش
(پورت ۸۰ و ۴۴۳) هم جواب می‌دهد. کسی که مستقیم به IP بزند و در هر درخواست یک XFFِ تازه بفرستد،
برای DRF هر بار «کاربرِ تازه» بود و از همه‌ی سقف‌های IP-محور (درخواستِ کد، حدسِ کد، ورودِ ادمین)
رد می‌شد (۲۰۲۶-۱۰-۱۳ روی سرور بازتولید شد: ۶۵ از ۶۵ درخواستِ health با XFFِ متفاوت ۲۰۰).
X-Real-IP را nginx خودش از آدرسِ TCP می‌گذارد (`proxy_params`ِ سایتِ `mathapp`؛ مقدارِ کلاینت را
بازنویسی می‌کند)، پس جعل‌پذیر نیست. اگر آن آدرس در TRUSTED_PROXY_NETWORKS نیست، XFF دور ریخته و
خودِ آن آدرس IPِ کاربر می‌شود. بی X-Real-IP (runserverِ محلی، تست‌ها) دست نمی‌زند.
سنجیده نشد: رنج‌های IPv6ِ آروان (فهرستِ آروان در این تاریخ فقط IPv4 داشت).
"""
WARN_EVERY_SECONDS = 60 * 60
def __init__(self, get_response):
from django.conf import settings
self.get_response = get_response
self.networks = settings.TRUSTED_PROXY_NETWORKS
def _trusted(self, peer: str) -> bool:
import ipaddress
try:
address = ipaddress.ip_address(peer)
except ValueError:
return False
return any(address in network for network in self.networks)
def __call__(self, request):
peer = request.META.get('HTTP_X_REAL_IP', '').strip()
if peer and not self._trusted(peer):
forwarded = request.META.get('HTTP_X_FORWARDED_FOR', '')
# XFF با دو مقدار یعنی پراکسیِ دیگری جلوی nginx بوده: یا کسی XFF جعل کرده، یا آروان رنجِ
# تازه‌ای گرفته که در فهرست نیست. دومی همه‌ی کاربرانِ آن edge را یک IP می‌کند و سقف‌ها برایشان
# زود پر می‌شود؛ پس هر peer ساعتی یک بار WARNING می‌گیرد تا در لاگ دیده شود.
if forwarded.count(',') >= 1:
from django.core.cache import cache
if cache.add(f'untrusted_proxy_warned:{peer}', 1, self.WARN_EVERY_SECONDS):
logger.warning('X-Forwarded-For from a peer outside TRUSTED_PROXY_NETWORKS ignored: '
'peer=%s (a new ArvanCloud range would also look like this)', peer)
request.META['HTTP_X_FORWARDED_FOR'] = peer
return self.get_response(request)
class APINoStoreMiddleware:
"""
هر پاسخِ `/api/` هدرِ `Cache-Control: no-store` می‌گیرد، مگر خودِ view
چیزِ دیگری گذاشته باشد (مثلاً استریمِ دستیار که `no-cache` دارد).
ArvanCloud جلوی سرور است و الان برای API کش نمی‌کند (`x-cache: BYPASS`)،
ولی این فقط به تنظیمِ پنلِ آروان بند است. پاسخ‌های API به کاربر وابسته‌اند
(توکنِ Bearer)؛ اگر روزی کسی کش را در پنل روشن کند، بدونِ این هدر یک
دانش‌آموز می‌توانست `/api/home` یا پروفایلِ دیگری را ببیند.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if request.path.startswith(_LOGGED_PATH_PREFIX) and not response.has_header('Cache-Control'):
response['Cache-Control'] = 'private, no-store'
return response
class DatabaseBusyMiddleware:
"""
poolِ دیتابیسِ پر → ۵۰۳ با Retry-After به‌جای ۵۰۰ (config/db.py) برای viewهای غیرِ DRF مثلِ /admin/؛
viewهای DRF را هندلرِ خطای DRF (config/exceptions.py) جواب می‌دهد، چون هر خطا را خودش ۵۰۰ می‌کند
و استثنا هیچ‌وقت به این‌جا نمی‌رسد. فقط خطای pool را می‌گیرد؛ هر OperationalErrorِ دیگری (Postgres
پایین، کوئریِ timeout‌خورده) همان ۵۰۰ِ قبلی می‌ماند.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
return self.get_response(request)
def process_exception(self, request, exception):
from django.http import JsonResponse
from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy
if not is_pool_timeout(exception):
return None
log_server_busy(request)
response = JsonResponse(SERVER_BUSY_BODY, status=503)
response['Retry-After'] = str(SERVER_BUSY_RETRY_AFTER)
return response
class AdminLoginThrottleMiddleware:
"""
سقفِ تلاشِ ورود به /admin/ برای هر IP (ADMIN_LOGIN_MAX_ATTEMPTS در ۱۵ دقیقه).
پنلِ ادمین روی دامنه‌ی عمومی است و فرمِ ورودِ جنگو هیچ محدودیتی ندارد؛
یعنی رمزِ ادمین (که همه‌ی محتوا و پرداخت‌ها را کنترل می‌کند) بی‌سقف قابلِ
حدس زدن بود. شمارش در کشِ پیش‌فرض است: تا CACHE_URL (Redis) تنظیم نشده،
برای هر worker جدا — یعنی سقفِ واقعی تا سه برابر. IP با همان منطقِ
NUM_PROXIESِ throttleهای DRF خوانده می‌شود.
"""
WINDOW_SECONDS = 15 * 60
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.method == 'POST' and request.path.startswith('/admin/login'):
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponse
from rest_framework.throttling import BaseThrottle
key = f'admin_login:{BaseThrottle().get_ident(request)}'
cache.add(key, 0, self.WINDOW_SECONDS)
try:
attempts = cache.incr(key)
except ValueError:
cache.set(key, 1, self.WINDOW_SECONDS)
attempts = 1
if attempts > getattr(settings, 'ADMIN_LOGIN_MAX_ATTEMPTS', 10):
logger.warning('admin login throttled: ident=%s attempts=%s', key, attempts)
return HttpResponse(
'تلاشِ ورود بیش از حد مجاز است؛ ۱۵ دقیقه‌ی دیگر دوباره امتحان کنید.',
status=429, content_type='text/plain; charset=utf-8',
)
return self.get_response(request)