Files
MathApp/docs/security-before-production.md
Zareian 645baf846b Signed payment links, hashed refresh tokens, login-only leaderboard, load test tools
- /api/payment/orders/{id}/pay requires the short-lived signature that paymentUrl carries;
  without it (or with another order's) it is the same 404 as an unknown order
- Session.refresh_token stores the SHA-256 of the token; migration accounts.0007 hashes the
  existing rows so tokens clients already hold keep working
- /api/leaderboard requires a token (guests have one); names of children were scrapable
- RAG mock only for `manage.py test`, not any command with an argument named "test"
- tools/loadtest: user factory, load generator, fake GapGPT+Qdrant, with method notes
- security-before-production.md lists only open items; closed ones become a guards table
  with the tests that hold them. Comments describe current behaviour, not history

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:12:28 +03:30

256 lines
24 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` سرور |
| ۱۰ | ورودِ مهمان فقط با `deviceId` است | کم (طراحی) | کد |
| ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد |
| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ حسابِ مهمان قابلِ بالا بردن است | کم (مالی) | کد |
| ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت |
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
| ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت |
| ۲۰ | یونیتِ systemd `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور |
| ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت |
| ۲۳ | سقفِ دستیار برای مهمان‌ها بر اساسِ IP مشترک است؛ کلاسِ پشتِ یک IP بعد از ۲۰ سوال در ساعت ۴۲۹ می‌گیرد | متوسط (دسترس‌پذیری) | تصمیمِ محصولی |
---
## ۱. کلیدِ امضای توکن (`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` با همین `deviceId` بزند، توکنِ همان
حساب را می‌گیرد.
- **خطر:** داده‌ی ساختگی در آمار و جدولِ رتبه‌بندیِ واقعی؛ یک حسابِ عمومی که هر کس
می‌تواند با آن کار کند (ثبتِ تیکت، گفتگو با AI به هزینه‌ی ما).
- **رفع:** پیش از کاربرِ واقعی پاک شوند. `purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ
واقعیِ viewها ساخته شده‌اند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ.
- **چطور بسنجید:** `POST /api/auth/session` با `frontend-test-device` باید `isNewUser: true` بدهد.
## ۴. `/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`) را فهرست می‌کند.
- **خطر:** شناساییِ کاملِ سطحِ حمله بدونِ زحمت.
- **چرا باز مانده:** قراردادِ مشترک با فرانت همین‌جاست؛ فرانت‌کار و ارائه به آن نیاز دارند.
- **رفع:** در پروداکشن `SERVE_PERMISSIONS` در `SPECTACULAR_SETTINGS` را به
`IsAdminUser` (staffِ جنگو) محدود کنید، یا مسیر را در nginx به IPهای تیم ببندید.
- **چطور بسنجید:** `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` باشد.
## ۱۰. ورودِ مهمان فقط با `deviceId`
- **چیست:** `POST /api/auth/session` با هر `deviceId` یا حسابِ مهمانِ همان دستگاه را
برمی‌گرداند یا می‌سازد (`accounts/views.py::GuestSessionView`). هیچ رازِ دیگری در کار نیست.
- **خطر:** هر کس `deviceId`ِ یک مهمان را بداند (از `localStorage`، لاگ، اسکرین‌شات) وارد
حسابِ او می‌شود. بعد از ثبت‌نام با شماره این راه بسته می‌شود (مهمان دیگر مهمان نیست).
- **رفع:** برای مهمان هم یک رازِ تصادفیِ سمتِ سرور صادر شود و ورودِ دوباره با همان راز باشد،
نه با `deviceId`ِ خام.
## ۱۱. کدِ OTP بی‌هش در دیتابیس
- **چیست:** `OTPCode.code` متنِ ساده است و در فهرستِ پنلِ ادمین (`OTPCodeAdmin`) دیده می‌شود.
توکنِ تمدید (`Session.refresh_token`) هش‌شده است و این‌جا شامل نیست.
- **چرا باز مانده:** تیم برای آزمایش بی‌پیامک یک ردیفِ `OTPCode` در پنل می‌سازد و کدش را می‌بیند.
- **خطر:** کسی که پنل یا دیتابیس را ببیند، در چند دقیقه‌ی عمرِ هر کد می‌تواند با آن وارد شود.
- **رفع:** ذخیره‌ی هش و حذفِ ستون از فهرستِ پنل، همراه با راهِ دیگری برای ورودِ آزمایشی.
## ۱۲. هزینه‌ی دستیارِ AI
- **چیست:** هر کاربر روزی ۳ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`)
و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. مهمان‌ها بر اساسِ IP محدود می‌شوند ولی هر IP ساعتی
۶۰ حسابِ مهمانِ تازه می‌سازد (`guest_session`)، و هر حسابِ تازه ۳ سؤالِ رایگانِ تازه دارد. سقفِ
روزانه‌ی کلِ سامانه (`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 کرد و فقط ساختِ مهمان را محدود نگه داشت. تا تصمیم، دست نخورد.
---
## حفاظ‌هایی که هست (تا دوباره باز نشوند)
هر تغییری در این مسیرها باید تست‌های نام‌برده را سبز نگه دارد. جزئیاتِ پرداخت و تخفیف در
`docs/architecture.md`.
| حفاظ | تست |
|---|---|
| پلنِ هر خرید روی سفارش؛ خرید اشتراکِ فعال را دست نمی‌زند؛ یک کدِ تخفیف پیش از درگاه؛ امتیاز فقط از جوابِ ثبت‌شده | `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` |