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

@@ -152,7 +152,9 @@ def _is_db_ref_id(value) -> bool:
"""
if value in (None, ''):
return False
return str(value).strip().lstrip('-').isdigit()
# isascii: '²'.isdigit() هم True است ولی int('²') خطا می‌دهد.
text = str(value).strip().lstrip('-')
return text.isascii() and text.isdigit()
def _db_ref_or_none(value):
@@ -606,7 +608,7 @@ class AskAIView(APIView):
event: error data: {"detail": "..."}
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
"""
from rag.services import answer_cache
from rag.services import answer_cache, guardrails
from rag.services.generation import (
PROMPT_VERSION, chat_model_for, default_follow_ups, stream_answer,
)
@@ -654,12 +656,18 @@ class AskAIView(APIView):
stream_result: dict = {}
started = time.monotonic()
effective_grade = retrieval_meta.get('gradeTitle') or grade_title or ''
for delta in stream_answer(
v['userQuestion'], used_chunks or None, effective_grade,
history=history, focus=focus, result=stream_result, age=age,
):
parts.append(delta)
yield _sse('chunk', {'delta': delta})
try:
for delta in stream_answer(
v['userQuestion'], used_chunks or None, effective_grade,
history=history, focus=focus, result=stream_result, age=age,
):
parts.append(delta)
yield _sse('chunk', {'delta': delta})
except GeneratorExit:
# کاربر صفحه را وسطِ جواب بست. سوال خرج می‌ماند (مدل هزینه‌اش را گرفته)، ولی
# همان بخشی که ساخته شد ثبت می‌شود تا گفتگو سوالِ بی‌جواب نداشته باشد.
self._save_partial_answer(conversation, user, v, ''.join(parts))
raise
provider_status.record_success()
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
follow_ups = default_follow_ups()
@@ -678,6 +686,7 @@ class AskAIView(APIView):
}
if stream_result.get('refused'):
used_chunks = []
answer_text = guardrails.REFUSAL
elif cacheable:
answer_cache.set(cache_key, {
'answer': answer_text, 'followUps': follow_ups,
@@ -752,6 +761,20 @@ class AskAIView(APIView):
response['X-Accel-Buffering'] = 'no'
return response
@staticmethod
def _save_partial_answer(conversation, user, v, text):
logger.info('stream closed by client mid-answer: user_id=%s conversation_id=%s chars=%s',
user.id, conversation.id, len(text))
if not text.strip():
return
try:
Message.objects.create(
conversation=conversation, role='assistant', text=text.strip() + ' …',
question_id=_db_ref_or_none(v.get('questionId')),
)
except Exception:
logger.exception('saving partial answer failed: conversation_id=%s', conversation.id)
def _release_reserved_slot(self, conversation, user_message, conversation_is_new):
"""پاک‌کردن رزرو فاز ۱ وقتی فاز بعدی شکست می‌خورد، تا نه پیام یتیم بماند و نه کوتا مصرف‌شده حساب شود."""
from subscription.services import refund_credit