Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks

- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and
  curriculum import need add/delete; content browser needs view_question; guest device ids are
  shown to superusers only.
- Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps
  Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted
  (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles.
- Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409;
  orders snapshot credits/hearts/days at creation (payment.0011).
- Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker
  refuses the answer (not stored, not cached); a disconnect saves the partial answer; only
  KNOWN_COLLECTIONS are searched.
- Infra: secret-key file is created atomically (workers no longer split keys); no default
  INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc
  pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400;
  ticket text capped at 5000.
- docs/security-before-production.md: remaining open items 24-31 and the guard tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 04:52:25 +03:30
parent 645baf846b
commit a973c41db2
36 changed files with 944 additions and 85 deletions

View File

@@ -7,6 +7,7 @@ from django.utils.html import escape
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
@@ -107,7 +108,7 @@ class CreateOrderView(APIView):
if v.get('planId'):
from subscription.models import Plan
plan = Plan.objects.filter(pk=v['planId']).first() if str(v['planId']).isdigit() else None
plan = Plan.objects.filter(pk=v['planId']).first() if (str(v['planId']).isascii() and str(v['planId']).isdigit()) else None
if plan is None:
raise ValidationError({'planId': 'پلنی با این شناسه وجود ندارد.'})
@@ -225,6 +226,10 @@ class ZarinpalCallbackView(APIView):
def get(self, request):
authority = request.query_params.get('Authority', '')
status_param = request.query_params.get('Status', '')
# بی‌Authority، جستجو هر سفارشی را می‌گرفت که صفحه‌ی پرداختش هنوز باز نشده (authority خالی):
# یک NOKِ ناشناس آن را «ناموفق» می‌کرد و با دو سفارش ۵۰۰ می‌داد.
if not authority:
raise ValidationError({'Authority': 'لازم است.'})
order = get_object_or_404(Order, gateway_authority=authority, provider='gateway')
if status_param != 'OK':
@@ -338,6 +343,8 @@ class BazaarVerifyPurchaseView(APIView):
# می‌شد؛ یعنی توکنِ خریدِ ارزان‌ترین محصول سفارشِ گران‌ترین پلن را «paid» می‌کرد.
if not order_lookup.bazaar_product_sku:
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']):
return self._second_purchase_response()
result = bazaar.verify_purchase(
product_sku=order_lookup.bazaar_product_sku,
purchase_token=v['purchaseToken'],
@@ -349,6 +356,8 @@ class BazaarVerifyPurchaseView(APIView):
pk=v['orderId'], user_id=request.user.id, provider='bazaar',
)
if self._second_purchase_on_paid_order(order, v['purchaseToken']):
return self._second_purchase_response()
new_status = 'paid' if result.is_valid else 'failed'
order.bazaar_purchase_token = v['purchaseToken']
order.save(update_fields=['bazaar_purchase_token'])
@@ -367,6 +376,20 @@ class BazaarVerifyPurchaseView(APIView):
data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid}
return Response(BazaarVerifyResponseSerializer(data).data)
@staticmethod
def _second_purchase_on_paid_order(order, purchase_token: str) -> bool:
"""
خریدِ دیگری برای سفارشی که با یک خریدِ دیگر پرداخت شده. پیش‌تر پاسخ «paid» بود، چیزی داده
نمی‌شد و توکنِ دوم هم برای هر سفارشِ بعدی سوخته بود؛ کاربر دو بار پول داده بود.
"""
return (order.status == 'paid' and bool(order.bazaar_purchase_token)
and order.bazaar_purchase_token != purchase_token)
@staticmethod
def _second_purchase_response():
return Response({'detail': 'این سفارش با خریدِ دیگری پرداخت شده است؛ برای این خرید سفارشِ تازه بساز.'},
status=status.HTTP_409_CONFLICT)
class OrderDetailView(APIView):
"""GET /api/payment/orders/{id} — وضعیت سفارش"""