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

@@ -53,21 +53,49 @@ if not SECRET_KEY:
else:
import secrets
import time
# سه workerِ gunicorn هم‌زمان به این‌جا می‌رسند. فایل فقط یک بار و اتمی ساخته می‌شود
# (O_EXCL، و نوشتن در فایلِ موقت + rename)، و بقیه همان را می‌خوانند؛ در غیرِ این صورت هر
# worker کلیدِ خودش را می‌ساخت و توکن فقط روی یکی از آن‌ها معتبر بود. سنجیده با سه
# پروسه‌ی هم‌زمان، نه با gunicornِ واقعی.
_key_file = BASE_DIR / '.django_secret_key'
try:
SECRET_KEY = _key_file.read_text().strip()
except OSError:
SECRET_KEY = ''
if not SECRET_KEY:
SECRET_KEY = secrets.token_urlsafe(64)
_lock_file = BASE_DIR / '.django_secret_key.lock'
SECRET_KEY = ''
for _attempt in range(50):
try:
_key_file.write_text(SECRET_KEY)
_key_file.chmod(0o600)
SECRET_KEY = _key_file.read_text().strip()
except FileNotFoundError:
SECRET_KEY = ''
except OSError as exc:
raise ImproperlyConfigured(f'خواندنِ {_key_file} ممکن نشد ({exc}).') from exc
if SECRET_KEY:
break
try:
_fd = os.open(_lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
time.sleep(0.1) # پروسه‌ی دیگری در حالِ ساختنِ کلید است
continue
except OSError as exc:
raise ImproperlyConfigured(
f'DJANGO_SECRET_KEY ست نیست و نوشتنِ {_key_file} هم ممکن نشد ({exc}). '
f'DJANGO_SECRET_KEY ست نیست و ساختنِ {_key_file} هم ممکن نشد ({exc}). '
'یا متغیر را ست کنید یا اجازه‌ی نوشتن در پوشه‌ی پروژه بدهید.'
) from exc
try:
os.close(_fd)
if not _key_file.exists():
_tmp = BASE_DIR / '.django_secret_key.tmp'
_tmp_fd = os.open(_tmp, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
with os.fdopen(_tmp_fd, 'w') as fh:
fh.write(secrets.token_urlsafe(64))
os.replace(_tmp, _key_file)
finally:
_lock_file.unlink(missing_ok=True)
if not SECRET_KEY:
raise ImproperlyConfigured(
f'کلیدِ امضا ساخته نشد: {_lock_file} مانده است. اگر هیچ پروسه‌ای در حالِ اجرا نیست، '
'آن را پاک کنید و دوباره راه‌اندازی کنید.'
)
def _env_list(name: str) -> list[str]:
return [v.strip() for v in os.environ.get(name, '').split(',') if v.strip()]
@@ -607,6 +635,12 @@ SPECTACULAR_SETTINGS = {
'filter': True,
'persistAuthorization': True,
},
# نسخه‌ی ثابت، نه @latest: این صفحه‌ها روی همان دامنه‌ی /admin/ اجرا می‌شوند، و نسخه‌ی منتشرشده
# در npm عوض‌شدنی نیست؛ با @latest هر نسخه‌ی آلوده‌ی تازه‌ی این بسته‌ها در مرورگرِ کارمند اجرا می‌شد.
# SRI ندارد. ارتقا یعنی عوض کردنِ همین دو عدد.
'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1',
'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/favicon-32x32.png',
'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@2.5.4',
}
# اعتبار توکن دسترسی (ثانیه) و رفرش‌توکن (روز)
@@ -702,6 +736,11 @@ def _parse_test_login_phones(raw: str) -> dict[str, str]:
TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES', ''))
# مهمان‌های نمایشی که با حذفِ حساب پاک نمی‌شوند (همان کاری که TEST_LOGIN_PHONES برای شماره‌ها می‌کند).
# پیش‌فرض: حسابِ مهمانِ تستِ فرانت (کاربرِ ۶ روی سرور)، که شناسه‌اش در ریپو و مستندات آمده و هر کسی
# می‌تواند با آن وارد شود. با پاک شدنِ داده‌ی دمو پیش از کاربرِ واقعی، این پیش‌فرض هم برداشته شود.
DEMO_GUEST_DEVICE_IDS = _env_list('DEMO_GUEST_DEVICE_IDS') or ['frontend-test-device']
# -----------------------------------------------------------------------
# OTP SMS delivery (accounts/services.py: send_otp_sms)
# -----------------------------------------------------------------------
@@ -745,10 +784,9 @@ ADMIN_LOGIN_MAX_ATTEMPTS = 10
# POST /api/payment/orders باید فقط سرور-به-سرور (از subscription) صدا زده شود؛
# کلاینت باید هدر X-Internal-Key برابر همین مقدار بفرستد. بدون env در
# پروداکشن مقدار خالی می‌ماند و HasInternalServiceKey صراحتاً رد می‌کند.
INTERNAL_SERVICE_KEY = os.environ.get(
'INTERNAL_SERVICE_KEY',
'dev-internal-service-key-change-me' if DEBUG else '',
)
# هیچ پیش‌فرضی، حتی در DEBUG: رشته‌ی پیش‌فرضِ داخلِ ریپو هر لپ‌تاپِ توسعه‌ای را که روی شبکه
# باز است به ingest در Qdrant راه می‌داد.
INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', '')
# -----------------------------------------------------------------------