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

@@ -76,15 +76,19 @@ def grant_pack(order) -> bool:
plan = Plan.objects.filter(pk=order.plan_id, kind='pack').first()
if plan is None:
return False
# عددها از خودِ سفارش (همان که فروخته شد)؛ سفارشِ بی‌این ستون‌ها (پیش از آن‌ها، یا ساخته‌شده بیرون
# از create_order) عددهای فعلیِ پلن را می‌گیرد، همان رفتارِ پیشین.
credits = order.question_credits if order.question_credits is not None else plan.question_credits
hearts_days = order.hearts_days if order.hearts_days is not None else plan.hearts_days
try:
with transaction.atomic():
QuestionCredit.objects.create(user_id=order.user_id, delta=plan.question_credits,
QuestionCredit.objects.create(user_id=order.user_id, delta=credits,
reason='purchase', order_id=order.id, note=plan.title[:100])
if plan.hearts_days:
if hearts_days:
now = timezone.now()
hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first()
start = hp.expires_at if hp and hp.expires_at > now else now
until = start + timezone.timedelta(days=plan.hearts_days)
until = start + timezone.timedelta(days=hearts_days)
HeartsPass.objects.update_or_create(user_id=order.user_id, defaults={'expires_at': until})
except IntegrityError:
return False
@@ -111,7 +115,7 @@ def ai_daily_quota(user_id: int) -> int:
return max(quotas) if quotas else settings.AI_DAILY_QUOTA_FREE
def activate_subscription(subscription_id: int, plan_id: int | None = None):
def activate_subscription(subscription_id: int, plan_id: int | None = None, duration_days: int | None = None):
"""
بعد از تاییدِ پرداخت صدا زده می‌شود (کال‌بکِ زرین‌پال، تاییدِ بازار).
@@ -136,7 +140,9 @@ def activate_subscription(subscription_id: int, plan_id: int | None = None):
# باید دیده شود. پلن را پاک نکنید؛ on_sale=False کنید.
logger.error('activate_subscription: پلنِ %s برای اشتراکِ %s پیدا نشد', plan_id, subscription_id)
plan = plan or subscription.plan
duration_days = plan.duration_days if plan else 30
# duration_days از سفارش (عددِ لحظه‌ی خرید)؛ None برای سفارشِ پیش از آن ستون.
if duration_days is None:
duration_days = plan.duration_days if plan else 30
now = timezone.now()
still_running = (
old_status == 'active' and subscription.expires_at is not None