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:
@@ -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} — وضعیت سفارش"""
|
||||
|
||||
Reference in New Issue
Block a user