throttling, abuse-protection & logging/monitoring pass

This commit is contained in:
2026-08-23 23:44:00 +03:30
parent cb18c93a3e
commit 80298b47b1
33 changed files with 4202 additions and 23 deletions

View File

@@ -1 +1,366 @@
# Create your tests here.
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import BugReport, Ticket, TicketMessage
class BugReportCreateThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ per-user روی BugReportCreateView
(throttle_scope='support_create'): هیچ idempotency ای روی این
endpoint نیست، پس بدونِ throttle یک کاربرِ احرازهویت‌شده می‌تونست
تعدادِ نامحدودی BugReport با درخواست‌های پرتکرار بسازه.
ScopedRateThrottle با pk کاربر کلید می‌خوره، نه IP.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-bugreport-throttle-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def _create(self, i=0, remote_addr=None):
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
'/api/support/bug-reports',
{'title': f'باگ {i}', 'description': f'توضیحات {i}'},
format='json',
**kwargs,
)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = self._create(i)
self.assertEqual(response.status_code, 200)
def test_excessive_requests_from_same_user_return_429(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = self._create(i)
self.assertEqual(response.status_code, 200)
blocked_response = self._create('extra')
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است)، چون طبقِ فیکسِ GuestAwareSupportThrottle حالا کلیدِ
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
# SupportCreateGuestThrottleBypassTests پایین‌تر) — این تست فقط
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه
# که باید دقیقاً مثلِ قبل دست‌نخورده بمونه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000901')
registered_token = issue_access_token(registered_user.id)
def _create_as(i, remote_addr):
return self.client.post(
'/api/support/bug-reports',
{'title': f'باگ {i}', 'description': f'توضیحات {i}'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
REMOTE_ADDR=remote_addr,
)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = _create_as(i, f'10.4.0.{i + 1}')
self.assertEqual(response.status_code, 200)
blocked_response = _create_as('extra', '10.4.0.99')
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_creates_no_additional_bug_report(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
first_response = self._create()
self.assertEqual(first_response.status_code, 200)
count_before = BugReport.objects.filter(user_id=self.user.id).count()
blocked_response = self._create('blocked')
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(BugReport.objects.filter(user_id=self.user.id).count(), count_before)
class TicketCreateThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ per-user روی TicketCreateView
(throttle_scope='support_create'، مشترک با BugReport/TicketMessage).
"""
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-ticket-throttle-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def _create(self, i=0, remote_addr=None):
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
'/api/support/tickets',
{'subject': f'موضوع {i}', 'category': 'billing'},
format='json',
**kwargs,
)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = self._create(i)
self.assertEqual(response.status_code, 200)
def test_excessive_requests_from_same_user_return_429(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = self._create(i)
self.assertEqual(response.status_code, 200)
blocked_response = self._create('extra')
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان
# عمداً بر اساسِ IP کلید می‌خورن (نگاه کنید به
# SupportCreateGuestThrottleBypassTests پایین‌تر)؛ این تست رفتارِ
# per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000902')
registered_token = issue_access_token(registered_user.id)
def _create_as(i, remote_addr):
return self.client.post(
'/api/support/tickets',
{'subject': f'موضوع {i}', 'category': 'billing'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
REMOTE_ADDR=remote_addr,
)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = _create_as(i, f'10.5.0.{i + 1}')
self.assertEqual(response.status_code, 200)
blocked_response = _create_as('extra', '10.5.0.99')
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_creates_no_additional_ticket(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
first_response = self._create()
self.assertEqual(first_response.status_code, 200)
count_before = Ticket.objects.filter(user_id=self.user.id).count()
blocked_response = self._create('blocked')
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(Ticket.objects.filter(user_id=self.user.id).count(), count_before)
class TicketMessageCreateThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ per-user روی TicketMessageCreateView
(throttle_scope='support_create'، مشترک با BugReport/Ticket).
"""
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-ticketmsg-throttle-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.ticket = Ticket.objects.create(user_id=self.user.id, subject='موضوع', category='billing')
def _create(self, i=0, remote_addr=None):
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
f'/api/support/tickets/{self.ticket.id}/messages',
{'text': f'پیام {i}'},
format='json',
**kwargs,
)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = self._create(i)
self.assertEqual(response.status_code, 200)
def test_excessive_requests_from_same_user_return_429(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = self._create(i)
self.assertEqual(response.status_code, 200)
blocked_response = self._create('extra')
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان
# عمداً بر اساسِ IP کلید می‌خورن (نگاه کنید به
# SupportCreateGuestThrottleBypassTests پایین‌تر)؛ این تست رفتارِ
# per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000903')
registered_token = issue_access_token(registered_user.id)
registered_ticket = Ticket.objects.create(
user_id=registered_user.id, subject='موضوع', category='billing',
)
def _create_as(i, remote_addr):
return self.client.post(
f'/api/support/tickets/{registered_ticket.id}/messages',
{'text': f'پیام {i}'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
REMOTE_ADDR=remote_addr,
)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
for i in range(3):
response = _create_as(i, f'10.6.0.{i + 1}')
self.assertEqual(response.status_code, 200)
blocked_response = _create_as('extra', '10.6.0.99')
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_creates_no_additional_message(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
first_response = self._create()
self.assertEqual(first_response.status_code, 200)
count_before = TicketMessage.objects.filter(ticket=self.ticket).count()
blocked_response = self._create('blocked')
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(TicketMessage.objects.filter(ticket=self.ticket).count(), count_before)
class SupportCreateGuestThrottleBypassTests(TestCase):
"""
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='support_create'
(مشترک بینِ BugReportCreateView، TicketCreateView و
TicketMessageCreateView) برای هر کاربرِ احرازهویت‌شده (مهمان یا نه)
user.pk بود. چون GuestSessionView با هر deviceId جدید یک کاربرِ
مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ
حساب‌های مهمان، هر بار یک bucket throttle تازه برای این scopeِ
مشترک بگیره و سقفِ ۳/ساعتِ ساختِ رکوردهای پشتیبانی (BugReport/
Ticket/TicketMessage) رو چند برابر کنه. حالا GuestAwareSupportThrottle
برای کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که
guest_session هم استفاده می‌کنه) می‌سازه، پس چرخشِ حساب مهمان دیگه
bucket تازه نمی‌ده — این هم روی bucket مشترکِ بینِ هر سه Endpoint و
هم روی رفتارِ per-user برای کاربرهای غیرمهمان تست می‌شه.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
def _create_bug_report(self, token, i=0, remote_addr=None):
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {token}'}
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
'/api/support/bug-reports',
{'title': f'باگ {i}', 'description': f'توضیحات {i}'},
format='json',
**kwargs,
)
def _create_ticket(self, token, i=0):
return self.client.post(
'/api/support/tickets',
{'subject': f'موضوع {i}', 'category': 'billing'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
# سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)،
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند،
# حتی وقتی از دو Endpointِ متفاوتِ همون scope استفاده می‌کنن.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '2/hour'}):
guest1 = User.objects.create(device_id='device-support-guest-rot-1', is_guest=True, name='')
response1 = self._create_bug_report(issue_access_token(guest1.id), 1)
self.assertEqual(response1.status_code, 200)
guest2 = User.objects.create(device_id='device-support-guest-rot-2', is_guest=True, name='')
response2 = self._create_ticket(issue_access_token(guest2.id), 2)
self.assertEqual(response2.status_code, 200)
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
# bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk — یعنی
# سقفِ ۲/ساعت هنوز اعمال می‌شه، صرف‌نظر از تعدادِ حساب‌های
# مهمانِ ساخته‌شده یا این‌که کدوم Endpoint صدا زده شده.
guest3 = User.objects.create(device_id='device-support-guest-rot-3', is_guest=True, name='')
response3 = self._create_bug_report(issue_access_token(guest3.id), 3)
self.assertEqual(response3.status_code, 429)
def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self):
# حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر
# (چهارمین pk) هم نباید bucket تازه‌ای بگیره — سقف هنوز به IP
# مشترک متصل است.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
guest1 = User.objects.create(device_id='device-support-guest-rot-4', is_guest=True, name='')
response1 = self._create_bug_report(issue_access_token(guest1.id), 4)
self.assertEqual(response1.status_code, 200)
guest2 = User.objects.create(device_id='device-support-guest-rot-5', is_guest=True, name='')
blocked = self._create_bug_report(issue_access_token(guest2.id), 5)
self.assertEqual(blocked.status_code, 429)
guest3 = User.objects.create(device_id='device-support-guest-rot-6', is_guest=True, name='')
still_blocked = self._create_bug_report(issue_access_token(guest3.id), 6)
self.assertEqual(still_blocked.status_code, 429)
def test_registered_users_keep_independent_per_user_buckets(self):
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ
# قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن — throttle
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
user_a = User.objects.create(is_guest=False, name='', phone='+989120001001')
response_a = self._create_bug_report(issue_access_token(user_a.id), 'a')
self.assertEqual(response_a.status_code, 200)
user_b = User.objects.create(is_guest=False, name='', phone='+989120001002')
response_b = self._create_bug_report(issue_access_token(user_b.id), 'b')
self.assertEqual(response_b.status_code, 200)
def test_blocked_guest_request_does_not_create_support_record(self):
# درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید هیچ BugReport یا
# Ticket جدیدی بسازه — throttle باید *قبل* از رسیدن به منطقِ
# ساختِ رکورد رد کنه.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
guest1 = User.objects.create(device_id='device-support-guest-rot-7', is_guest=True, name='')
first_response = self._create_bug_report(issue_access_token(guest1.id), 7)
self.assertEqual(first_response.status_code, 200)
# حسابِ مهمانِ تازه‌ی دوم باید بلاک بشه (bucket مشترکِ IP از
# قبل مصرف شده) و نباید BugReport یا Ticket جدیدی بسازد.
guest2 = User.objects.create(device_id='device-support-guest-rot-8', is_guest=True, name='')
bug_count_before = BugReport.objects.count()
ticket_count_before = Ticket.objects.count()
blocked_bug_response = self._create_bug_report(issue_access_token(guest2.id), 8)
self.assertEqual(blocked_bug_response.status_code, 429)
blocked_ticket_response = self._create_ticket(issue_access_token(guest2.id), 8)
self.assertEqual(blocked_ticket_response.status_code, 429)
self.assertEqual(BugReport.objects.count(), bug_count_before)
self.assertEqual(Ticket.objects.count(), ticket_count_before)

View File

@@ -2,6 +2,7 @@ from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .models import BugReport, Ticket, TicketMessage
@@ -16,10 +17,57 @@ from .serializers import (
)
class GuestAwareSupportThrottle(ScopedRateThrottle):
"""
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py)
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ
احرازهویت‌شده از request.user.pk به‌عنوانِ کلیدِ throttle استفاده
می‌کنه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ
تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های
مهمان، برای هر حساب یک bucket تازه از throttle_scope 'support_create'
بگیره. چون هر سه Endpointِ این اپ (BugReportCreateView،
TicketCreateView، TicketMessageCreateView) این scope رو به‌اشتراک
می‌ذارن و هرکدوم واقعاً یک رکورد در دیتابیس می‌سازن (BugReport،
Ticket، یا TicketMessage)، چرخشِ حساب مهمان می‌تونست محدودیتِ
۳/ساعتِ در نظر گرفته‌شده رو چند برابر کنه و باعثِ spam بشه.
این کلاسِ throttle یک‌بار اینجا تعریف شده و بینِ هر سه Endpoint
به‌اشتراک گذاشته می‌شه (به‌جایِ تکرارِ یک کلاسِ مشابه برای هرکدوم)،
دقیقاً چون هر سه از یک throttle_scope مشترک استفاده می‌کنن. فقط
برای کاربرهای مهمان (is_guest=True) کلیدِ cache از pk کاربر به IP
کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی حساب‌های مهمانِ ساخته‌شده
از یک IP یک bucket مشترک دارن و چرخشِ حساب دیگه bucket تازه نمی‌ده.
رفتارِ throttle برای کاربرهای غیرمهمان (ثبت‌نام‌شده) دقیقاً مثلِ قبل
(کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ ساختِ رکورد/ownership/serializer
در هر سه ویو دست‌نخورده‌ست.
"""
def get_cache_key(self, request, view):
user = request.user
if user and user.is_authenticated and getattr(user, 'is_guest', False):
ident = f'guest-ip:{self.get_ident(request)}'
elif user and user.is_authenticated:
ident = user.pk
else:
ident = self.get_ident(request)
return self.cache_format % {'scope': self.scope, 'ident': ident}
class BugReportCreateView(APIView):
"""POST /api/support/bug-reports — ثبت گزارش باگ"""
permission_classes = [IsAuthenticated]
# هیچ مکانیزمِ idempotency ای اینجا نیست، پس بدونِ throttle یک کاربرِ
# احرازهویت‌شده می‌تونه تعدادِ نامحدودی BugReport با درخواست‌های
# پرتکرار بسازه. ScopedRateThrottle با pk کاربر کلید می‌خوره (نه IP).
# ⚠️ اصلاح شده: چون کاربرهای مهمان با هر deviceId جدید یک pk تازه
# می‌گیرن، از GuestAwareSupportThrottle استفاده می‌شه تا چرخشِ حساب
# مهمان دیگه bucket تازه ندهد (نگاه کنید به تعریفِ کلاس بالا).
throttle_scope = 'support_create'
throttle_classes = [GuestAwareSupportThrottle]
@extend_schema(
request=BugReportCreateRequestSerializer,
@@ -46,6 +94,13 @@ class TicketCreateView(APIView):
"""POST /api/support/tickets — ایجاد تیکت"""
permission_classes = [IsAuthenticated]
# مثلِ BugReportCreateView: هیچ idempotency ای نیست، پس این endpoint هم
# همون scope=support_create رو به‌اشتراک می‌ذاره تا محدودیتِ ساختِ
# رکوردهای پشتیبانی، مستقل از این‌که کدوم نوع رکورده، رعایت بشه.
# ⚠️ اصلاح شده: از همون GuestAwareSupportThrottleِ مشترک استفاده می‌شه
# تا چرخشِ حساب مهمان bucket تازه ندهد.
throttle_scope = 'support_create'
throttle_classes = [GuestAwareSupportThrottle]
@extend_schema(
request=TicketCreateRequestSerializer,
@@ -107,6 +162,12 @@ class TicketMessageCreateView(APIView):
"""
permission_classes = [IsAuthenticated]
# مثلِ دو Endpoint بالا: بدونِ throttle، یک کاربر می‌تونست تعدادِ
# نامحدودی پیام در یک تیکت بسازه.
# ⚠️ اصلاح شده: از همون GuestAwareSupportThrottleِ مشترک استفاده می‌شه
# تا چرخشِ حساب مهمان bucket تازه ندهد.
throttle_scope = 'support_create'
throttle_classes = [GuestAwareSupportThrottle]
@extend_schema(
request=TicketMessageCreateRequestSerializer,