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:
@@ -1,6 +1,6 @@
|
||||
# پیش از پروداکشنِ واقعی: چیزهایی که عمداً باز ماندهاند
|
||||
|
||||
**آخرین بازبینی با کد و سرورِ زنده: ۲۰۲۶-۱۰-۰۴ (۱۲ مهر ۱۴۰۵).**
|
||||
**آخرین بازبینی با کد: ۲۰۲۶-۱۰-۰۵ (۱۳ مهر ۱۴۰۵)؛ با سرورِ زنده: ۲۰۲۶-۱۰-۰۴.**
|
||||
|
||||
این پروژه تا این تاریخ در حالتِ **دمو و ارائهی کارآموزی** است، نه محصولِ نهایی. چند چیز
|
||||
عمداً باز گذاشته شده تا توسعهدهندهها، فرانتکار و ارائه کار کنند. هر کدام برای یک سرورِ
|
||||
@@ -38,6 +38,14 @@
|
||||
| ۲۰ | یونیتِ systemd `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور |
|
||||
| ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت |
|
||||
| ۲۳ | سقفِ دستیار برای مهمانها بر اساسِ IP مشترک است؛ کلاسِ پشتِ یک IP بعد از ۲۰ سوال در ساعت ۴۲۹ میگیرد | متوسط (دسترسپذیری) | تصمیمِ محصولی |
|
||||
| ۲۴ | سقفِ روزانهی ۳۰۰ پیامکِ کلِ سامانه را یک ناشناس پر میکند و ورود با پیامک برای همه بسته میشود | بالا (دسترسپذیری، هزینه) | تصمیمِ طراحی |
|
||||
| ۲۵ | با دانستنِ شمارهی کسی، ورودِ او تا ۲۴ ساعت بسته میشود | متوسط | تصمیمِ طراحی |
|
||||
| ۲۶ | پایانِ درس و جزیره به گفتهی کلاینت است (جزیرهی قفل، قلبِ صفر، گزارشِ والدین) | متوسط | کد، بعد از ارائه |
|
||||
| ۲۷ | کلیدِ جوابِ هر سوال با یک حسابِ دوم درمیآید؛ امتیازِ هر جوابِ درست دو بار داده میشود | کم | تصمیمِ محصولی |
|
||||
| ۲۸ | استفادهی دوباره از توکنِ تمدیدِ دزدیدهشده تشخیص داده نمیشود؛ «خروج از همهی دستگاهها» نیست | کم | کد، بعد از ارائه |
|
||||
| ۲۹ | پیشنهادِ ثبتنام با حذفِ حساب و ورودِ دوباره با همان شماره دوباره داده میشود | کم | کد |
|
||||
| ۳۰ | چند مسیرِ خواندنی (از جمله بانکِ سوال، بیورود) throttle ندارند | کم | کد، بعد از ارائه |
|
||||
| ۳۱ | لپتاپِ توسعه با `mobile-preview` روی شبکهی محلی باز است و در DEBUG کدِ `0000` را میپذیرد | کم | لپتاپِ توسعه |
|
||||
|
||||
---
|
||||
|
||||
@@ -82,7 +90,9 @@
|
||||
- **خطر:** دادهی ساختگی در آمار و جدولِ رتبهبندیِ واقعی؛ یک حسابِ عمومی که هر کس
|
||||
میتواند با آن کار کند (ثبتِ تیکت، گفتگو با AI به هزینهی ما).
|
||||
- **رفع:** پیش از کاربرِ واقعی پاک شوند. `purge_demo_data` اینها را **نمیگیرد** (از مسیرِ
|
||||
واقعیِ viewها ساخته شدهاند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ.
|
||||
واقعیِ viewها ساخته شدهاند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ. همراهِ آن پیشفرضِ
|
||||
`DEMO_GUEST_DEVICE_IDS` در `config/settings.py` (که حذفِ این مهمان را با `POST /api/user/delete`
|
||||
میبندد) هم برداشته شود.
|
||||
- **چطور بسنجید:** `POST /api/auth/session` با `frontend-test-device` باید `isNewUser: true` بدهد.
|
||||
|
||||
## ۴. `/api/health` عمومی و پرجزئیات
|
||||
@@ -107,8 +117,12 @@
|
||||
(`X-Internal-Key`) را فهرست میکند.
|
||||
- **خطر:** شناساییِ کاملِ سطحِ حمله بدونِ زحمت.
|
||||
- **چرا باز مانده:** قراردادِ مشترک با فرانت همینجاست؛ فرانتکار و ارائه به آن نیاز دارند.
|
||||
- **رفع:** در پروداکشن `SERVE_PERMISSIONS` در `SPECTACULAR_SETTINGS` را به
|
||||
`IsAdminUser` (staffِ جنگو) محدود کنید، یا مسیر را در nginx به IPهای تیم ببندید.
|
||||
- **رفع:** مسیر را در 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/` باید ۴۰۱/۴۰۳ بدهد.
|
||||
|
||||
## ۷. پنلِ ادمین روی دامنهی عمومی
|
||||
@@ -148,7 +162,9 @@
|
||||
- **چیست:** `POST /api/auth/session` با هر `deviceId` یا حسابِ مهمانِ همان دستگاه را
|
||||
برمیگرداند یا میسازد (`accounts/views.py::GuestSessionView`). هیچ رازِ دیگری در کار نیست.
|
||||
- **خطر:** هر کس `deviceId`ِ یک مهمان را بداند (از `localStorage`، لاگ، اسکرینشات) وارد
|
||||
حسابِ او میشود. بعد از ثبتنام با شماره این راه بسته میشود (مهمان دیگر مهمان نیست).
|
||||
حسابِ او میشود. بعد از ثبتنام با شماره این راه بسته میشود (مهمان دیگر مهمان نیست) و همهی
|
||||
نشستهایی که پیش از آن گرفته شده بودند باطل میشوند. شناسهی دستگاه در پنل فقط برای ابرکاربر
|
||||
نمایش داده میشود.
|
||||
- **رفع:** برای مهمان هم یک رازِ تصادفیِ سمتِ سرور صادر شود و ورودِ دوباره با همان راز باشد،
|
||||
نه با `deviceId`ِ خام.
|
||||
|
||||
@@ -234,6 +250,72 @@
|
||||
سقفِ سراسریِ `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.
|
||||
|
||||
---
|
||||
|
||||
## حفاظهایی که هست (تا دوباره باز نشوند)
|
||||
@@ -249,7 +331,19 @@
|
||||
| جدولِ رتبهبندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) |
|
||||
| شمارهی موبایل نرمال میشود؛ cooldown، سقفِ روزانهی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده میشوند | `accounts/tests_otp_limits.py` |
|
||||
| OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکنهای قبلی را باطل میکند | `accounts/tests_audit_fixes.py` |
|
||||
| قلبِ صفر جوابِ سوال و پایانِ درس/جزیره را میبندد؛ چکش داخلِ تراکنش است | `content/tests_hearts_gate.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` |
|
||||
|
||||
Reference in New Issue
Block a user