Files
MathApp/docs/security-before-production.md
Zareian 8835e01595 Remove guest login: every user registers with a phone number
POST /api/auth/session is gone (404). Existing is_guest rows keep refresh and
upgrade-by-phone until the demo/guest rows are purged; the code that serves
them is listed in docs/security-before-production.md §10. Tests that used the
endpoint as a fixture now use accounts/testing.guest_session.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 17:38:18 +03:30

361 lines
39 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# پیش از پروداکشنِ واقعی: چیزهایی که عمداً باز مانده‌اند
**آخرین بازبینی با کد: ۲۰۲۶-۱۰-۰۵ (۱۳ مهر ۱۴۰۵)؛ با سرورِ زنده: ۲۰۲۶-۱۰-۰۴.**
این پروژه تا این تاریخ در حالتِ **دمو و ارائه‌ی کارآموزی** است، نه محصولِ نهایی. چند چیز
عمداً باز گذاشته شده تا توسعه‌دهنده‌ها، فرانت‌کار و ارائه کار کنند. هر کدام برای یک سرورِ
عمومی با کاربرِ واقعی (کودک، پرداختِ واقعی) **خطر است** و باید پیش از آمدنِ اولین کاربرِ
واقعی بسته شود.
**برای agentها و توسعه‌دهنده‌های بعدی:** این فهرست ادعاست، نه ضمانت؛ ممکن است از این
تاریخ به بعد چیزی عوض شده باشد. پیش از بستنِ هر مورد، وضعیتِ فعلی‌اش را با خودِ سرور یا
کد بسنجید (ستونِ «چطور بسنجید»). مورد که بسته شد، از فهرست برداشته می‌شود و یک خط در
«حفاظ‌هایی که هست» (پایینِ همین فایل) می‌گیرد، با نامِ تستی که آن را نگه می‌دارد. تاریخ و
دلیلِ تغییر در پیامِ کامیت است، نه این‌جا.
هر آزمونِ «از بیرون» با `curl` بدونِ توکن انجام شده و در تاریخِ بالا تأیید شده است.
---
## خلاصه، به ترتیبِ خطر
| # | مورد | خطر | کارِ کیست |
|---|---|---|---|
| ۱ | کلیدِ امضای توکن روی سرور همان رشته‌ی داخلِ ریپو است | بحرانی — جعلِ ورود به‌جای هر کاربر | کد آماده؛ اعمال روی سرور |
| ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور |
| ۳ | داده‌ی دمو و حسابِ مهمانِ نمایشیِ `frontend-test-device` | بالا | دیتابیسِ سرور |
| ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان می‌دهد | متوسط | کد |
| ۵ | Swagger، ReDoc و schemaی کاملِ API عمومی‌اند | متوسط | کد |
| ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد |
| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور |
| ۹ | کشِ مشترک (Redis) نیست؛ محدودیت‌ها per-worker‌اند | متوسط | `.env` سرور |
| ۱۰ | حساب‌های مهمانِ قدیمی (دمو و کاربرانِ پیش از حذفِ ورودِ مهمان) هنوز در دیتابیس‌اند | کم | دیتابیسِ سرور |
| ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد |
| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ حسابِ تازه (هر شماره ۳ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
| ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت |
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
| ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت |
| ۲۰ | یونیتِ systemd `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور |
| ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت |
| ۲۳ | سقفِ دستیار برای مهمان‌ها بر اساسِ IP مشترک است؛ کلاسِ پشتِ یک IP بعد از ۲۰ سوال در ساعت ۴۲۹ می‌گیرد | متوسط (دسترس‌پذیری) | تصمیمِ محصولی |
| ۲۴ | سقفِ روزانه‌ی ۳۰۰ پیامکِ کلِ سامانه را یک ناشناس پر می‌کند و ورود با پیامک برای همه بسته می‌شود | بالا (دسترس‌پذیری، هزینه) | تصمیمِ طراحی |
| ۲۵ | با دانستنِ شماره‌ی کسی، ورودِ او تا ۲۴ ساعت بسته می‌شود | متوسط | تصمیمِ طراحی |
| ۲۶ | پایانِ درس و جزیره به گفته‌ی کلاینت است (جزیره‌ی قفل، قلبِ صفر، گزارشِ والدین) | متوسط | کد، بعد از ارائه |
| ۲۷ | کلیدِ جوابِ هر سوال با یک حسابِ دوم درمی‌آید؛ امتیازِ هر جوابِ درست دو بار داده می‌شود | کم | تصمیمِ محصولی |
| ۲۸ | استفاده‌ی دوباره از توکنِ تمدیدِ دزدیده‌شده تشخیص داده نمی‌شود؛ «خروج از همه‌ی دستگاه‌ها» نیست | کم | کد، بعد از ارائه |
| ۲۹ | پیشنهادِ ثبت‌نام با حذفِ حساب و ورودِ دوباره با همان شماره دوباره داده می‌شود | کم | کد |
| ۳۰ | چند مسیرِ خواندنی (از جمله بانکِ سوال، بی‌ورود) throttle ندارند | کم | کد، بعد از ارائه |
| ۳۱ | لپ‌تاپِ توسعه با `mobile-preview` روی شبکه‌ی محلی باز است و در DEBUG کدِ `0000` را می‌پذیرد | کم | لپ‌تاپِ توسعه |
---
## ۱. کلیدِ امضای توکن (`DJANGO_SECRET_KEY`) روی سرور
- **چیست:** توکنِ ورود فقط `user_id`ِ امضاشده با `django.core.signing` است
(`accounts/authentication.py`). امضا با `DJANGO_SECRET_KEY` انجام می‌شود. روی سرور این
مقدار هنوز همان fallbackِ `django-insecure-…` است که در `config/settings.py` کامیت شده
(آخرین تأیید: ۲۰۲۶-۰۹-۲۴، به‌صورتِ بولین).
- **خطر:** هر کس ریپو را ببیند (Gitea، zipِ تحویلی، هر کپی) می‌تواند برای **هر `user_id`**
توکنِ معتبر بسازد و به‌جای آن کاربر وارد شود؛ از جمله خریدِ اشتراک، دیدنِ گفتگوها و
داده‌ی خانواده.
- **چرا باز مانده:** عوض کردنش همه‌ی کاربرهای واردشده (توسعه‌دهنده‌ها، فرانت‌کار، حساب‌های
دمو در حالِ ارائه) را بیرون می‌اندازد. تصمیمِ صاحبِ پروژه: بعد از ارائه.
- **رفع:** `DJANGO_SECRET_KEY` را از `.env` **و** از یونیتِ systemd حذف کنید تا
`config/settings.py` یک کلیدِ تصادفی بسازد و در `.django_secret_key` (مجوز `0600`) نگه
دارد. سرویس را ری‌استارت کنید. همه یک بار دوباره وارد می‌شوند.
- **چطور بسنجید:** `manage.py check --deploy` نباید `security.W009` بدهد؛ روی سرور
`venv/bin/python -c "from django.conf import settings; print(settings.SECRET_KEY.startswith('django-insecure'))"`
باید `False` چاپ کند (خودِ کلید را چاپ نکنید).
## ۲. حساب‌های دمو با کدِ ورودِ ثابت (`TEST_LOGIN_PHONES`)
- **چیست:** ۷ شماره‌ی `09120000101` تا `09120000107` با کدِ ثابت در `.env` سرور. شماره‌ها
در ریپو (`docs/operations.md`، `CLAUDE.md`) آمده‌اند؛ کدها فقط در راهنمای ارائه‌ی صاحبِ
پروژه‌اند.
- **خطر:** کسی که کد را از راهنما، اسکرین‌شات یا ارائه ببیند وارد آن حساب‌ها می‌شود. این
کدها با `OTP_MAX_FAILED_ATTEMPTS` باطل نمی‌شوند (ردیفِ `OTPCode` ندارند)؛ فقط سقفِ
per-workerِ تلاشِ ورود جلویشان است.
- **رفع:** `TEST_LOGIN_PHONES` را از `.env` سرور پاک کنید و ری‌استارت کنید.
- **چطور بسنجید:** `/api/health` دیگر هشدارِ «شماره‌های تستِ ورود با کدِ ثابت فعال‌اند» ندارد.
## ۳. داده‌ی دمو و مهمانِ عمومی
- **افزوده ۲۰۲۶-۰۹-۲۵:** کاربرِ ۱۷ (`09000000001`، «کاربر تست»، `deviceId =
deploy-check-20260925`) از تستِ دیپلویِ پیامک، و کاربرِ ۱۸ (مهمان، `deploy-check-ai-20260925`)
از تستِ دستیار؛ همراهِ بقیه پاک شوند.
- **چیست:** کاربرهای ۶ (`deviceId = frontend-test-device`) و ۱۰ تا ۱۵، با درس، گفتگو،
تیکت، خانواده و زیرمجموعه، روی دیتابیسِ پروداکشن ساخته شده‌اند. `frontend-test-device`
در ریپو نوشته شده. `POST /api/auth/session` حذف شده، پس با آن دیگر توکن نمی‌دهد؛ ولی خودِ
ردیف‌ها (با `is_guest=True`) تا پاک نشوند در دیتابیس‌اند.
- **خطر:** داده‌ی ساختگی در آمار و جدولِ رتبه‌بندیِ واقعی؛ یک حسابِ عمومی که هر کس
می‌تواند با آن کار کند (ثبتِ تیکت، گفتگو با AI به هزینه‌ی ما).
- **رفع:** پیش از کاربرِ واقعی پاک شوند. `purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ
واقعیِ viewها ساخته شده‌اند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ. همراهِ آن پیش‌فرضِ
`DEMO_GUEST_DEVICE_IDS` در `config/settings.py` (که حذفِ این مهمان را با `POST /api/user/delete`
می‌بندد) هم برداشته شود.
- **چطور بسنجید:** در پنل یا shell، `User.objects.filter(is_guest=True).count()` پس از پاک‌سازی باید صفر باشد.
## ۴. `/api/health` عمومی و پرجزئیات
- **چیست:** `config/health.py`، `AllowAny`. بدونِ توکن برمی‌گرداند: `debug`، `warnings`
(مثلاً «شماره‌های تست فعال‌اند»، «محدودیتِ نرخ بینِ workerها مشترک نیست»)، `integrations`
(کدام سرویس‌ها پیکربندی‌اند، وضعیتِ اعتبارِ AI، نامِ مدل)، `traffic` (تعدادِ کاربر، فعالِ
۲۴ ساعتِ اخیر، زمانِ آخرین فعالیت)، `allowedOrigins`، `clientIp`، شمارِ محتوا و مهاجرت‌ها.
- **خطر:** نقشه‌ی حمله: مهاجم می‌فهمد محدودیت‌ها سه‌برابر دورزدنی‌اند، حسابِ کدِ ثابت
هست، اعتبارِ AI کی تمام شده، و ترافیکِ واقعی چقدر است.
- **چرا باز مانده:** صاحبِ پروژه و agentها بدونِ دسترسیِ SSH از همین صفحه سلامتِ سرور را
می‌بینند.
- **رفع:** پاسخِ عمومی فقط `{"status": "ok"|"degraded"}`؛ بقیه پشتِ هدرِ
`X-Internal-Key` (`INTERNAL_SERVICE_KEY`) یا فقط برای staff.
- **چطور بسنجید:** `curl https://mathapi.tadrisino2.ir/api/health` بدونِ هدر نباید
`warnings` و `traffic` داشته باشد.
## ۵. مستنداتِ API عمومی
- **چیست:** `/api/docs/` (Swagger)، `/api/redoc/` و `/api/schema/` بدونِ ورود باز‌اند
(تأیید: HTTP 200). schema همه‌ی endpointها، پارامترها، کدهای خطا و هدرهای داخلی
(`X-Internal-Key`) را فهرست می‌کند.
- **خطر:** شناساییِ کاملِ سطحِ حمله بدونِ زحمت.
- **چرا باز مانده:** قراردادِ مشترک با فرانت همین‌جاست؛ فرانت‌کار و ارائه به آن نیاز دارند.
- **رفع:** مسیر را در nginx به IPهای تیم ببندید، یا در `SPECTACULAR_SETTINGS` هر دوِ
`SERVE_PERMISSIONS = ['rest_framework.permissions.IsAdminUser']` و
`SERVE_AUTHENTICATION = ['rest_framework.authentication.SessionAuthentication']` را بگذارید.
`IsAdminUser` به‌تنهایی کار نمی‌کند: احرازِ پیش‌فرضِ DRF فقط توکنِ Bearer است، پس ادمینِ واردشده
به پنل هم ۴۰۱ می‌گیرد (آزموده شد). اسکریپتِ Swagger/ReDoc نسخه‌ی ثابت دارد (نه `@latest`)، چون
روی همان دامنه‌ی `/admin/` اجرا می‌شود.
- **چطور بسنجید:** `curl -o /dev/null -w "%{http_code}" …/api/schema/` باید ۴۰۱/۴۰۳ بدهد.
## ۷. پنلِ ادمین روی دامنه‌ی عمومی
- **چیست:** `/admin/` روی `mathapi.tadrisino2.ir`، با ورودِ نام‌کاربری/رمزِ جنگو. سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)،
ولی: شمارش per-worker است (مورد ۹)، IP با XFF جعل‌پذیر است (مورد ۸)، و ۲FA ندارد.
- **خطر:** ادمین همه‌ی محتوا، پلن‌ها، کدهای تخفیف، سفارش‌ها و کاربرها را کنترل می‌کند.
- **رفع:** محدود کردنِ `/admin/` در nginx به IPهای تیم یا VPN؛ تغییرِ مسیرِ پیش‌فرض؛ ۲FA؛
رمزهای قوی و حسابِ staffِ جدا برای هر نفر.
## ۸. دسترسیِ مستقیم به پورتِ ۸۰ (دور زدنِ ArvanCloud)
- **چیست:** `http://78.38.35.249/api/health` با `Host: mathapi.tadrisino2.ir` پاسخ می‌دهد
(تأیید: HTTP 200). `NUM_PROXIES=2` فرض می‌کند همیشه دو پروکسی جلوی ماست.
- **خطر:** کسی که IP را بداند `X-Forwarded-For` را جعل می‌کند و محدودیت‌های IP-محور
(مهمان، OTP، ادمین) را دور می‌زند.
- **رفع (ادمینِ سرور):** پورتِ ۸۰/۴۴۳ فقط برای رنج‌های ArvanCloud، یا `set_real_ip_from` +
`real_ip_header` در nginx.
- **بازتولید شد ۲۰۲۶-۱۰-۰۴:** `curl -H 'Host: mathapi.tadrisino2.ir' -H 'X-Forwarded-For: 1.2.3.4'
http://78.38.35.249/api/health` جواب داد `clientIp: 1.2.3.4`. یعنی هر سقفِ IP-محور (ورودِ ادمین
۱۰ در ۱۵ دقیقه، درخواست و ورودِ OTP، ساختِ مهمان، دستیارِ مهمان) با عوض کردنِ این هدر در هر
درخواست دور زده می‌شود، و حفاظتِ DDoS/WAFِ آروان هم برای این مسیر وجود ندارد. سقف‌های
per-phone و per-code که در دیتابیس شمرده می‌شوند متأثر نیستند. پورت‌های ۸۰ و ۴۴۳ هر دو باز بودند.
## ۹. نبودِ کشِ مشترک (`CACHE_URL`)
- **چیست:** بدونِ `CACHE_URL` هر worker کشِ `LocMemCache` خودش را دارد (۳ worker).
- **خطر:** همه‌ی محدودیت‌های مبتنی بر کش تا سه برابر شل‌ترند: throttleهای DRF، cooldown و
سقفِ درخواستِ OTP، سقفِ تلاشِ ورودِ OTP به‌ازای شماره، سقفِ ورودِ ادمین؛ و باطل‌شدنِ کش
بعد از تغییرِ ادمین فقط در یک worker. (سقفِ حدسِ هر کدِ OTP در دیتابیس است و متأثر نیست.)
- **رفع:** `CACHE_URL=redis://127.0.0.1:6379/1` (db0 مالِ اپِ دیگری است) و ری‌استارت.
- **چطور بسنجید:** `/api/health` → `checks.cache.backend` نباید `LocMemCache` باشد.
## ۱۰. حساب‌های مهمانِ قدیمی
- **چیست:** ورودِ مهمان (`POST /api/auth/session`) حذف شده و هر کاربرِ تازه با شماره ثبت‌نام می‌کند.
ردیف‌های مهمانِ پیشین (`User.is_guest=True`، شامل دموها) در دیتابیس مانده‌اند و کدِ ارتقای مهمان به
حسابِ شماره‌دار (`LoginView`) و throttleهای مهمان‌محور هنوز برایشان کار می‌کنند.
- **خطر:** مهمانِ قدیمی هنوز با `refreshToken` ذخیره‌شده در مرورگرش وارد می‌ماند؛ شناسه‌ی دستگاه دیگر
راهِ ورود نیست.
- **رفع:** پس از پاک‌سازیِ دموها (بند ۳) و دیدنِ این‌که مهمانِ واقعی نمانده، کدِ مهمان
(`is_guest`، `GuestAware*Throttle`، ارتقا) حذف شود. تا آن روز عمداً نگه داشته شده.
## ۱۱. کدِ OTP بی‌هش در دیتابیس
- **چیست:** `OTPCode.code` متنِ ساده است و در فهرستِ پنلِ ادمین (`OTPCodeAdmin`) دیده می‌شود.
توکنِ تمدید (`Session.refresh_token`) هش‌شده است و این‌جا شامل نیست.
- **چرا باز مانده:** تیم برای آزمایش بی‌پیامک یک ردیفِ `OTPCode` در پنل می‌سازد و کدش را می‌بیند.
- **خطر:** کسی که پنل یا دیتابیس را ببیند، در چند دقیقه‌ی عمرِ هر کد می‌تواند با آن وارد شود.
- **رفع:** ذخیره‌ی هش و حذفِ ستون از فهرستِ پنل، همراه با راهِ دیگری برای ورودِ آزمایشی.
## ۱۲. هزینه‌ی دستیارِ AI
- **چیست:** هر کاربر روزی ۳ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`)
و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. هر شماره‌ی تازه ۳ سؤالِ رایگان می‌گیرد و ساختنِ شماره با سقفِ پیامک
(بند ۲۴) محدود است. سقفِ
روزانه‌ی کلِ سامانه (`AI_GLOBAL_DAILY_MAX`، ۳۰۰۰) خرجِ کل را محدود می‌کند. طولِ سؤال حداکثر ۲۰۰۰ نویسه است.
- **خطر:** با چند IP، اعتبارِ GapGPT در چند ساعت تمام می‌شود (اعتبار یک بار در ۲۰۲۶-۰۹-۲۵
به صفر رسید، البته به دلیلِ دیگر).
- **رفع:** سقفِ سراسریِ روزانه برای کلِ اپ؛ دستیار فقط برای کاربرِ واردشده با شماره یا
با سقفِ بسیار کمتر برای مهمان؛ هشدار وقتی `aiProvider.state` در `/api/health` برابرِ
`quota_exhausted` شد.
## ۱۴. دیتابیس و بکاپ
- **چیست:** SQLite در `~/mathapp-v2/db.sqlite3`، روی دیسکی که بینِ چند سرویس مشترک است و
~۸۳٪ پر است. `transaction_mode: IMMEDIATE` هم‌زمانی را درست ولی سریالی می‌کند. بکاپ دستی
است (`deploy.sh` پیش از `migrate` بکاپ نمی‌گیرد) و در همان پوشه می‌ماند.
- **خطر:** از دست رفتنِ کلِ داده با یک خطای دیسک یا یک دستورِ اشتباه؛ پُر شدنِ دیسک
سرویس‌های دیگر (Gitea، ایمیل) را هم می‌خواباند.
- **رفع:** مهاجرت به Postgres (روی همان ماشین نصب است)، بکاپِ خودکار پیش از هر `migrate`، و
نگه‌داریِ بکاپ بیرون از ماشین.
## ۱۵. دسترسیِ سرور
- **چیست:** ورود با رمز به حسابِ مشترکِ `administrator` با `sudo (ALL : ALL) ALL` و عضوِ
`docker` و `lxd`، روی ماشینی که Gitea و ایمیلِ شرکت هم رویش است. رمز یک بار در گفتگو با
agent گذاشته شده و روی دسکتاپِ صاحبِ پروژه در یک فایلِ متنی نگه داشته می‌شود.
- **خطر:** هر نشتِ رمز یعنی دسترسیِ root به کلِ ماشینِ شرکت، نه فقط این پروژه.
۲۰۲۶-۱۰-۰۴: `PasswordAuthentication yes` در `sshd_config` و پورتِ ۲۲ از اینترنت باز است؛ پس رمز
از هر جای اینترنت حدس‌زدنی است (fail2ban سنجیده نشد).
- **رفع (ادمینِ سرور):** کلیدِ SSH به‌جای رمز، تعویضِ رمز، حسابِ جدا برای این پروژه با
دسترسیِ فقط به `~/mathapp-v2` و یک خطِ `NOPASSWD` فقط برای
`systemctl restart zareian-mathapp`؛ پاک کردنِ فایلِ رمز از دسکتاپ (Keychain جایگزین است).
## ۱۶. نگه‌داریِ توکن در فرانت
- **چیست:** کلاینتِ فرانت (`src/api/index.ts` در مخزنِ فرانت) توکن و refresh را در
`localStorage` نگه می‌دارد.
- **خطر:** هر XSS در سایت هر دو را می‌خواند.
- **رفع:** CSPِ سخت‌گیرانه روی `math.tadrisino2.ir`؛ در بلندمدت refresh در کوکیِ `HttpOnly`.
## ۲۰. یونیتِ systemd: DEBUG و کلیدِ ناامن
- **چیست:** `systemctl cat gunicorn-mathapp` هنوز `Environment="DJANGO_DEBUG=1"` و
`Environment="DJANGO_SECRET_KEY=django-insecure-…"` دارد. `EnvironmentFile=.env` بعد از آن می‌آید و
`.env` خطِ `DJANGO_DEBUG=0` دارد (شمرده شد، مقدار چاپ نشد)؛ برای همین الان DEBUG خاموش است.
- **خطر:** اگر روزی آن یک خط از `.env` پاک شود (مثلاً با این فکر که «پیش‌فرضِ DEBUG خاموش است»)،
بعد از ری‌استارت DEBUG روشن می‌شود و کدِ `0000` برای هر شماره‌ای پذیرفته می‌شود. `deploy.sh` این را
نمی‌بیند، چون تنظیماتِ مؤثر را با محیطِ شِل می‌خواند نه محیطِ یونیت.
- **رفع (ادمینِ سرور، با بکاپِ یونیت):** هر دو خطِ `Environment=` را از یونیت بردارید، `daemon-reload`،
ری‌استارت. حذفِ خطِ کلید به‌تنهایی کسی را بیرون نمی‌اندازد اگر `.env` همان کلید را داشته باشد؛
عوض کردنِ خودِ کلید (مورد ۱) جداست.
## ۲۲. هدرهای امنیتیِ فرانت
- **چیست:** `https://math.tadrisino2.ir/` فقط `x-xss-protection` دارد؛ نه CSP، نه `X-Frame-Options`/
`frame-ancestors`، نه HSTS، نه `X-Content-Type-Options`. API همه‌ی این‌ها را جز HSTS و CSP دارد.
- **خطر:** صفحه‌ی اپ را می‌شود در iframeِ سایتِ دیگری گذاشت (clickjacking)؛ و چون توکن در
`localStorage` است (مورد ۱۶)، نبودِ CSP اثرِ هر XSSِ احتمالی را کامل می‌کند. در خودِ فرانت سینکِ XSS
پیدا نشد (تنها `dangerouslySetInnerHTML` روی SVGِ ثابتِ شکلک‌هاست).
- **رفع:** در nginxِ `mathapp-front` یا در آروان: `Content-Security-Policy` با `frame-ancestors 'none'`
و `connect-src` به دامنه‌ی API، و `X-Content-Type-Options: nosniff`.
## ۲۳. سقفِ دستیار برای مهمان‌ها روی IPِ مشترک
- **چیست:** `GuestAwareAIThrottle` برای مهمان‌ها سطلِ `ai_ask` (۲۰ در ساعت) را بر اساسِ IP می‌سازد تا
ساختنِ مهمانِ تازه سقف را دور نزند.
- **بازتولید شد:** ۲۲ مهمانِ متفاوت از یک IP، هر کدام اولین سوالش: ۲۰ تا ۲۰۰، ۲۱اُم و ۲۲اُم ۴۲۹.
دانش‌آموزانِ مهمانِ یک کلاس پشتِ وای‌فایِ مدرسه یا CGNAT با هم ساعتی ۲۰ سوال دارند (روی سرور با ۳
workerِ بی‌Redis تا حدودِ ۶۰).
- **چرا تصمیمِ محصولی است:** سقفِ IP جلوی «مهمان بساز، سه سوالِ رایگان بگیر» را می‌گیرد.
سقفِ سراسریِ `AI_GLOBAL_DAILY_MAX` (۳۰۰۰ در روز، حدودِ ۶ دلار) هزینه را جدا محدود
می‌کند، پس می‌شود سطل را per-user کرد و فقط ساختِ مهمان را محدود نگه داشت. تا تصمیم، دست نخورد.
## ۲۴. پر کردنِ سقفِ سراسریِ پیامک
- **چیست:** `OTP_SMS_GLOBAL_DAILY_MAX` (۳۰۰ در ۲۴ ساعت) جلوی خرجِ بی‌سقفِ پیامک را می‌گیرد. هر
ناشناسی با درخواستِ کد برای شماره‌های تصادفی آن را پر می‌کند؛ از یک IP بی‌جعل ساعتی ۲۰ درخواست،
یعنی روزی ۴۸۰، پس یک نفر سقف را همیشه پر نگه می‌دارد.
- **بازتولید شد:** ۳۰۰ درخواست پذیرفته و ۳۰۰ پیامک فرستاده شد؛ بعد درخواستِ کاربرِ واقعی ۵۰۳.
- **خطر:** هیچ ثبت‌نامِ تازه، ورودِ دوباره یا حذفِ حسابی ممکن نیست؛ هزینه‌ی ۳۰۰ پیامک در روز.
- **رفع (تصمیمِ طراحی):** بعد از گذشتن از یک حد، CAPTCHA یا مانعِ مشابه پیش از ارسال؛ شمارشِ سقف
بر اساسِ IP/شبکه؛ هشدار در نیمه‌ی سقف.
## ۲۵. بستنِ ورودِ یک شماره‌ی مشخص
- **چیست:** ۱۰ درخواستِ کد در ~۲ ساعت سقفِ روزانه‌ی آن شماره را پر می‌کند (صاحبش تا ۲۴ ساعت کد
نمی‌گیرد)، و ۵ حدسِ غلطِ هر کسی کدِ تازه‌ی او را باطل می‌کند. هر دو بازتولید شد.
- **رفع (تصمیمِ طراحی):** کد به درخواست‌کننده گره بخورد (درخواستِ کد یک شناسه‌ی موقت برگرداند که
ورود لازمش دارد) و حدسِ غلط برای هر درخواست‌کننده جدا شمرده شود.
## ۲۶. پایانِ درس و جزیره به گفته‌ی کلاینت
- **چیست:** `POST /api/nodes/{id}/quiz/complete` و `POST /api/lessons/{id}/complete` تعدادِ سوال و
جوابِ درست را از کلاینت می‌گیرند. بازتولید شد: با قلبِ صفر و `totalQuestions=1` جزیره تمام
می‌شود؛ جزیره‌ی قفل با `correctCount=0` تمام می‌شود؛ درس و جزیره بی‌هیچ جوابی تمام می‌شوند، پس
`GET /api/user/weekly-report` پیشرفتِ ۱۰۰٪ نشان می‌دهد بی‌آن‌که سوالی جواب داده شده باشد.
- **چرا باز مانده:** رفتارِ آزمون در فرانت به همین شکل است؛ عوض کردنش پیش از ارائه جریانِ نقشه را
به خطر می‌اندازد.
- **رفع:** تعدادِ سوالِ آزمون را سرور تعیین کند (همان `/quiz` با seed)، همان تعداد جوابِ ثبت‌شده
لازم باشد، و جزیره‌ی قبلی تمام شده باشد.
## ۲۷. کلیدِ جواب با حسابِ دوم، امتیازِ دوباره
- **چیست:** جوابِ غلط `correctOptionId` را برمی‌گرداند و برای همه یکی است؛ پایانِ درس و پایانِ
جزیره هر کدام جدا امتیاز می‌دهند. یک مهمانِ دورریختنی کلید را درمی‌آورد و حسابِ اصلی همه را درست
می‌زند (۴ سوال، ۸۰ امتیاز). رتبه‌بندی و آمارِ درصدِ غلط آلوده می‌شوند.
- **رفع (تصمیمِ محصولی):** امتیازِ هر سوال یک بار؛ گزینه‌ی درست فقط بعد از پایانِ آزمون.
## ۲۸. توکنِ تمدیدِ دزدیده‌شده
- **چیست:** استفاده‌ی دوباره از یک توکنِ تمدیدِ باطل‌شده فقط ۴۰۱ می‌گیرد؛ اگر دزد اول تمدید کند،
صاحبِ حساب بیرون می‌افتد و دزد می‌ماند. «خروج از همه‌ی دستگاه‌ها» هم نیست.
- **چرا باز مانده:** باطل کردنِ کلِ زنجیره با استفاده‌ی دوباره، حساب‌های دمو را که در ارائه چند
نفر هم‌زمان با آن‌ها کار می‌کنند بیرون می‌اندازد.
- **رفع:** شناسه‌ی خانواده روی `Session`؛ با استفاده‌ی دوباره کلِ خانواده باطل شود؛ گزینه‌ی خروج از همه‌جا.
## ۲۹. پیشنهادِ ثبت‌نام بعد از حذفِ حساب
- **چیست:** پیشنهادِ خودکارِ ثبت‌نام به `user_id` گره خورده است؛ حذفِ حساب و ورودِ دوباره با همان
شماره یک کاربرِ تازه می‌سازد و پیشنهاد دوباره داده می‌شود. فقط وقتی اثر دارد که یک Offerِ
ثبت‌نام فعال باشد.
- **رفع:** گره زدنِ پیشنهادِ ثبت‌نام به هشِ شماره.
## ۳۰. مسیرهای خواندنیِ بی‌throttle
- **چیست:** `lessons/{id}/questions` و `lessons/{id}/intro` (بی‌ورود)، `progress/nodes`،
`user/hearts`، `notifications/{id}/read`، `support/tickets/{id}` و چند مسیرِ دیگر `throttle_scope`
ندارند؛ کلِ بانکِ سوال بی‌ورود قابلِ کپی است. (`/api/schema/` سقفِ `read` دارد.)
- **چرا باز مانده:** سقفِ مهمان‌ها بر اساسِ IP است (مورد ۲۳)؛ افزودنِ سقف به این مسیرها برای کلاسی
که پشتِ یک وای‌فای با مهمان کار می‌کند ۴۲۹ می‌سازد. همراهِ تصمیمِ مورد ۲۳ بسته شود.
## ۳۱. لپ‌تاپِ توسعه روی شبکه‌ی محلی
- **چیست:** `mobile-preview/serve.py` عمداً روی کلِ شبکه گوش می‌دهد (برای گوشیِ روی همان وای‌فای) و
`/api/` را به runserverِ محلی می‌فرستد؛ در DEBUG کدِ `0000` هر شماره‌ای را وارد می‌کند و کلیدِ
GapGPTِ لپ‌تاپ همان حسابِ سرور است. (`docker compose` فقط روی `127.0.0.1` باز است و کلیدِ سرویسِ
داخلی دیگر پیش‌فرض ندارد.)
- **رفع:** روی شبکه‌ی عمومی (کافه، مدرسه) اجرا نشود؛ برای توسعه کلیدهای جدای GapGPT و Qdrant.
---
## حفاظ‌هایی که هست (تا دوباره باز نشوند)
هر تغییری در این مسیرها باید تست‌های نام‌برده را سبز نگه دارد. جزئیاتِ پرداخت و تخفیف در
`docs/architecture.md`.
| حفاظ | تست |
|---|---|
| ورودِ مهمان وجود ندارد (`POST /api/auth/session` ۴۰۴ می‌دهد و کاربر نمی‌سازد)؛ حسابِ مهمانِ قدیمی همچنان refresh و ارتقا دارد | `accounts/tests.py` (`test_guest_login_endpoint_is_gone`)، `accounts/tests_guest_refresh.py` |
| پلنِ هر خرید روی سفارش؛ خرید اشتراکِ فعال را دست نمی‌زند؛ یک کدِ تخفیف پیش از درگاه؛ امتیاز فقط از جوابِ ثبت‌شده | `payment/tests_audit_fixes.py`، `accounts/tests_audit_fixes.py` |
| لینکِ پرداخت (`/api/payment/orders/{id}/pay`) فقط با امضای کوتاه‌عمرِ همراهِ `paymentUrl` باز می‌شود | `payment/tests.py` (`PaymentRedirectViewTests`) |
| توکنِ تمدید فقط به‌صورتِ هشِ SHA-256 ذخیره می‌شود | `accounts/tests.py` (`RefreshTokenStoredHashedTests`) |
| جدولِ رتبه‌بندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) |
| شماره‌ی موبایل نرمال می‌شود؛ cooldown، سقفِ روزانه‌ی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده می‌شوند | `accounts/tests_otp_limits.py` |
| OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکن‌های قبلی را باطل می‌کند | `accounts/tests_audit_fixes.py` |
| قلبِ صفر جوابِ سوال را می‌بندد؛ چکش داخلِ تراکنش است (پایانِ جزیره را فقط کامل نمی‌بندد، مورد ۲۶) | `content/tests_hearts_gate.py` |
| `select_for_update` مؤثر با `transaction_mode: IMMEDIATE`؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` |
| دیتابیس و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | با `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` روی سرور باید خطای دسترسی بدهد |
| کتابخانه‌ها بدونِ آسیب‌پذیریِ شناخته‌شده (`urllib3>=2.8.0`، `python-dotenv 1.2.2`) | پایگاهِ OSV روی `pip freeze` |
| actionهای لغو/بازپرداختِ سفارش، علامتِ «بررسی‌شده» و لینکِ بازار اجازه‌ی تغییر می‌خواهند؛ کارمندِ فقط‌خواندنی (Supervisor) اجرایشان نمی‌کند | `payment/tests_security_review.py`، `content/tests_security_review.py` |
| ورودِ فایلِ سوال/برنامه‌ی درسی اجازه‌ی افزودن (و برای جایگزینی، حذف) می‌خواهد؛ مرورگرِ محتوا اجازه‌ی دیدنِ سوال | `content/tests_security_review.py` |
| شناسه‌ی دستگاهِ مهمان فقط برای ابرکاربر در پنل دیده می‌شود؛ ثبتِ شماره نشست‌های قبلیِ مهمان را باطل می‌کند | `accounts/tests_security_review.py` |
| حذفِ حساب سابقه‌ی کدِ تخفیف، پیشنهادها و کدهای پیامکیِ شماره را نگه می‌دارد؛ مهمانِ نمایشی حذف نمی‌شود | `accounts/tests_security_review.py` |
| صافیِ اطلاعاتِ تماس در نامِ نمایشی (شماره با هر جداکننده، آیدیِ شبکه‌ی اجتماعی) | `accounts/tests_security_review.py` |
| کلیدِ کاوه‌نگار و توکنِ خریدِ بازار در لاگِ خطای شبکه نمی‌آیند | `accounts/tests_security_review.py` |
| برگشت از زرین‌پال بی‌`Authority` ۴۰۰؛ خریدِ دومِ بازار روی سفارشِ پرداخت‌شده ۴۰۹؛ آنچه داده یا پس گرفته می‌شود از خودِ سفارش است، نه پلنِ فعلی | `payment/tests_security_review.py` |
| شکلِ خرابِ مدل جواب را رایگان نمی‌کند؛ علامتِ «خارج از ریاضی» در هر جای استریم جواب را رد می‌کند و در کش نمی‌رود؛ بستنِ صفحه جوابِ ناقص را ثبت می‌کند؛ collectionِ بیرون از `KNOWN_COLLECTIONS` جستجو نمی‌شود | `ai_tutor/tests_security_review.py` |
| ورودیِ عددیِ غیرِ ASCII (مثلِ `²`) ۴۰۰ است نه ۵۰۰؛ متنِ تیکت/گزارشِ باگ حداکثر ۵۰۰۰ نویسه | `content/tests_security_review.py` |
| `INTERNAL_SERVICE_KEY` در هیچ حالتی پیش‌فرض ندارد | `payment/tests.py` (`test_debug_mode_has_no_default_internal_key`) |
| کلیدِ امضای خودساخته (`.django_secret_key`) یک بار و اتمی ساخته می‌شود؛ سه workerِ هم‌زمان یک کلید دارند | دستی: سه پروسه‌ی هم‌زمان که `config.settings` را import می‌کنند، بی‌فایلِ کلید؛ ۲۰ از ۲۰ یک کلید |
| فرانت: شماره‌ی سفارشِ برگشتی از درگاه فقط عدد است، مسیرها encode می‌شوند و «پرداخت موفق» فقط از پاسخِ سرور می‌آید؛ دو تبِ هم‌زمان با تمدیدِ توکن خارج نمی‌شوند | مخزنِ فرانت: `src/app/launchParams.ts`، `src/api/http.ts` |
| سفارش و تراکنش در پنل کاملاً فقط‌خواندنی‌اند (کارمندِ لغو/بازپرداخت مبلغ یا اعتبارِ سفارش را عوض نمی‌کند) و از پنل ساخته نمی‌شوند | `payment/tests_security_review.py` (`OrderAdminMoneyFieldsReadOnlyTests`) |
| ردِ بازار توکنِ خرید را نمی‌سوزاند؛ سفارشِ بازپرداخت‌شده توکنِ تازه نمی‌پذیرد | `payment/tests_security_review.py` (`BazaarRejectedVerifyDoesNotBurnTokenTests`) |
| کدِ تخفیفِ وسطِ رفتن به درگاه، مبلغِ authority را با سفارش ناهمخوان نمی‌کند | `payment/tests_security_review.py` (`DiscountDuringPaymentRequestTests`) |
| پرداختِ بسته‌ای که پلنش پاک شده از عددهای ثبت‌شده‌ی سفارش اعتبار می‌دهد (و اگر نداشت ERROR لاگ می‌شود)؛ پلن فقط با IRT ذخیره می‌شود | `subscription/tests_security_review.py` (`PaidPackWithDeletedPlanTests`، `PlanCurrencyIsTomanOnlyTests`) |
| توکنِ بازار (`refresh_token`، `pending_state`) با فیلترِ querystringِ پنل حرف‌به‌حرف درنمی‌آید | `payment/tests_security_review.py` (`BazaarTokenNotGuessableByAdminFilterTests`) |
| `device_id` با فیلترِ querystringِ پنل برای غیرِ ابرکاربر درنمی‌آید؛ شماره و `is_guest` را فقط ابرکاربر عوض می‌کند؛ حساب‌های کارمند و گروه‌ها فقط با ابرکاربر ساخته/تغییر می‌کنند | `accounts/tests_security_review.py` (`DeviceIdNotGuessableByAdminFilterTests`، `StaffCannotSwapLoginPhoneTests`، `StaffAccountsAdminSuperuserOnlyTests`) |
| لاگِ تحویلِ کمپین اجازه‌ی view می‌خواهد (هر کارمندی نمی‌بیند) | `campaigns/tests_security_review.py` |
| هر رقمِ یونیکد (تمام‌عرض، ریاضی…) در شماره به لاتین برمی‌گردد؛ کدِ غیرِ ASCII، `deviceId` null، پایه‌ی ناموجود یا عظیم و تاریخِ تولدِ با «²» ۴۰۰ است نه ۵۰۰؛ صافیِ نامِ نمایشی ارقامِ یونیکد و جداکننده‌ی دلخواه را می‌گیرد | `accounts/tests_security_review.py` (`UnicodeInputsAreNormalisedOrRejectedTests`) |
| مهمانِ نمایشیِ مشترک با ورودِ یک شماره به آن شماره تبدیل نمی‌شود | `accounts/tests_security_review.py` (`DemoGuestNotUpgradableTests`) |
| `/api/home?gradeId=` ناموجود کش نمی‌شود (کشِ پُر شمارنده‌ی سقفِ ورودِ ادمین را پاک می‌کرد)؛ `/api/progress/nodes` یک کوئری است و `unit` عظیم ۴۰۰ | `content/tests_security_review.py` (`JunkGradeIdsDoNotFlushCacheTests`، `ProgressNodesInputAndQueriesTests`) |