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

@@ -1053,8 +1053,20 @@ def _normalize(obj):
return obj
# بزرگ‌ترین شکلِ معتبرِ سنجش‌ها چند کیلوبایت بود؛ بیش از این نشانه‌ی خروجیِ خراب یا دست‌کاری‌شده است.
MAX_DIAGRAM_CHARS = 8000
def parse(body: str) -> dict | None:
"""
شکلِ معتبر یا None. هیچ خطایی بیرون نمی‌رود: شکل اختیاری است، و خطایی که از این‌جا بالا می‌رفت
(OverflowError از عددِ ۴۰۰ رقمی، RecursionError از JSONِ خیلی تودرتو) در استریم کلِ جواب را
«شکست» حساب می‌کرد و سوال را رایگان و بی‌ثبت می‌گذاشت، در حالی که متن به کاربر رسیده بود.
"""
body = (body or '').strip()
if len(body) > MAX_DIAGRAM_CHARS:
logger.warning('diagram: بلوکِ شکل %s نویسه بود (سقف %s)، دور ریخته شد', len(body), MAX_DIAGRAM_CHARS)
return None
if body.startswith('```'):
body = body.strip('`').removeprefix('json').strip()
try:
@@ -1062,6 +1074,9 @@ def parse(body: str) -> dict | None:
except (json.JSONDecodeError, TypeError, ValueError):
logger.info('diagram: JSONِ نامعتبر از مدل، دور ریخته شد')
return None
except Exception:
logger.exception('diagram: خطای پیش‌بینی‌نشده در خواندنِ شکل، دور ریخته شد')
return None
class StreamSplitter:

View File

@@ -402,7 +402,15 @@ def stream_answer(question: str, context_chunks: list[dict] | None,
if not clean:
continue
if released:
yield clean.replace(guardrails.OFF_TOPIC_MARKER, '')
if guardrails.OFF_TOPIC_MARKER in clean:
# علامت دیر آمد: بخشِ پیش از آن رفته است، ولی بقیه نمی‌رود و جواب «ردشده» حساب می‌شود
# تا نه ذخیره شود نه در کش برود (پیش‌تر کاربرِ بعدی همین متن را از کش می‌گرفت).
head = clean.split(guardrails.OFF_TOPIC_MARKER, 1)[0]
if head.strip():
yield head
refused = True
continue
yield clean
continue
held += clean
if guardrails.OFF_TOPIC_MARKER in held:
@@ -420,7 +428,7 @@ def stream_answer(question: str, context_chunks: list[dict] | None,
result['diagram'] = None if refused else diagram_mod.fits(
f'{question}\n{focus}\n{_previous_question(history)}', splitter.diagram)
if refused:
yield guardrails.REFUSAL
yield ('\n\n' if released else '') + guardrails.REFUSAL
return
if not released and held.strip():
yield held

View File

@@ -10,7 +10,9 @@ import logging
from .config import build_collection_name, resolve_grade_title, retrieval_top_k
from .embedding import embed_text
from .known_collections import filter_key_for_subject, is_math_subject, resolve_math_chapter
from .known_collections import (
KNOWN_COLLECTIONS, filter_key_for_subject, is_math_subject, resolve_math_chapter,
)
from .qdrant_store import search
logger = logging.getLogger(__name__)
@@ -29,6 +31,12 @@ def resolve_target(subject: str, grade_title: str, grade_id, lesson_number):
return None, None, None
collection = build_collection_name(subject, grade_title)
if collection not in KNOWN_COLLECTIONS:
# subject و grade می‌توانند از بدنه‌ی درخواست بیایند؛ بی‌این چک هر کاربری از هر collectionِ
# Qdrantِ مشترک (از جمله مالِ تیمِ دیگر) متن می‌خواند و در usedChunks پس می‌گرفت.
# نتیجه همان رفتارِ collectionِ ناموجود است: جوابِ بی‌context.
logger.warning('rag.retrieve: collection %r در KNOWN_COLLECTIONS نیست، جستجو نشد', collection)
return None, None, None
filter_key = filter_key_for_subject(subject)
if is_math_subject(subject):
filter_value = resolve_math_chapter(subject, grade_title, lesson_number)