diff --git a/CLAUDE.md b/CLAUDE.md index 176055e..197857c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,15 @@ at checkout, `select_for_update` guards that SQLite silently ignores). race-free / the right architecture"; write "guards X; not tested under Y" instead. Record uncertainty plainly so the next reader re-checks rather than trusts. +## Before any real user: read `docs/security-before-production.md` + +The server is deliberately left in demo mode for the internship presentation: the token +signing key is the one committed in this repo, seven fixed-code demo logins exist, demo +users sit in the production DB, and `/api/health`, the API schema, the leaderboard and +`/admin/` are all public. Each is listed there with its risk, why it was left, and the +fix. **Do not treat this deployment as production-ready until every item there is closed**, +and record the date and commit of each fix in that file rather than deleting the item. + ## Commands ```bash diff --git a/README.md b/README.md index 768edd8..b7673a6 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ curl https://api.math.tadrisino2.ir/api/health | سند | موضوع | |---|---| | [`docs/operations.md`](docs/operations.md) | دیپلوی، متغیرهای محیطی، سلامت، بکاپ، ورودِ توسعه‌دهنده‌ها | +| [`docs/security-before-production.md`](docs/security-before-production.md) | **پیش از کاربرِ واقعی:** هرچه برای دمو عمداً باز مانده، خطرش و راهِ بستنش | | [`docs/content-authoring.md`](docs/content-authoring.md) | افزودن و ویرایشِ محتوا از پنلِ ادمین، ساختِ انبوهِ سوال | | [`docs/architecture.md`](docs/architecture.md) | تصمیم‌های معماری و دلیلشان | | [`docs/frontend/README.md`](docs/frontend/README.md) | راهنمای اتصالِ فرانت‌اند + کلاینتِ تایپ‌دار | diff --git a/docs/operations.md b/docs/operations.md index 203157d..01cf963 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -230,3 +230,10 @@ sudo systemctl restart gunicorn # مالِ ما نیست — اپِ هر خطای پیش‌بینی‌نشده همیشه پاسخِ JSON برمی‌گرداند — نه صفحه‌ی HTML پیش‌فرضِ جنگو — و کامل با traceback لاگ می‌شود (`config/exceptions.py`). + +--- + +> **پیش از آمدنِ کاربرِ واقعی** فهرستِ [`security-before-production.md`](security-before-production.md) +> را کامل ببندید: کلیدِ امضا، حساب‌های دمو، جزئیاتِ عمومیِ `/api/health`، مستنداتِ عمومیِ API، +> جدولِ رتبه‌بندیِ عمومی، پنلِ ادمین، پورتِ ۸۰ و دسترسیِ سرور. + diff --git a/docs/security-before-production.md b/docs/security-before-production.md new file mode 100644 index 0000000..0ac88b4 --- /dev/null +++ b/docs/security-before-production.md @@ -0,0 +1,220 @@ +# پیش از پروداکشنِ واقعی: چیزهایی که عمداً باز مانده‌اند + +**تاریخِ ثبت: ۲۰۲۶-۰۹-۲۵ (۳ مهر ۱۴۰۵)، کامیتِ پایه `66066cb`.** + +این پروژه تا این تاریخ در حالتِ **دمو و ارائه‌ی کارآموزی** است، نه محصولِ نهایی. چند چیز +عمداً باز گذاشته شده تا توسعه‌دهنده‌ها، فرانت‌کار و ارائه کار کنند. هر کدام برای یک سرورِ +عمومی با کاربرِ واقعی (کودک، پرداختِ واقعی) **خطر است** و باید پیش از آمدنِ اولین کاربرِ +واقعی بسته شود. + +**برای agentها و توسعه‌دهنده‌های بعدی:** این فهرست ادعاست، نه ضمانت؛ ممکن است از این +تاریخ به بعد چیزی عوض شده باشد. پیش از بستنِ هر مورد، وضعیتِ فعلی‌اش را با خودِ سرور یا +کد بسنجید (ستونِ «چطور بسنجید»). بعد از بستنِ هر مورد، همین‌جا تاریخ و کامیتِ رفعش را +بنویسید؛ مورد را پاک نکنید. + +هر آزمونِ «از بیرون» با `curl` بدونِ توکن انجام شده و در تاریخِ بالا تأیید شده است. + +--- + +## خلاصه، به ترتیبِ خطر + +| # | مورد | خطر | کارِ کیست | +|---|---|---|---| +| ۱ | کلیدِ امضای توکن روی سرور همان رشته‌ی داخلِ ریپو است | بحرانی — جعلِ ورود به‌جای هر کاربر | کد آماده؛ اعمال روی سرور | +| ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور | +| ۳ | داده‌ی دمو و حسابِ مهمانِ عمومیِ `frontend-test-device` | بالا | دیتابیسِ سرور | +| ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان می‌دهد | متوسط | کد | +| ۵ | Swagger، ReDoc و schemaی کاملِ API عمومی‌اند | متوسط | کد | +| ۶ | جدولِ رتبه‌بندی نام و شناسه‌ی کودکان را بدونِ ورود نشان می‌دهد | متوسط (حریمِ خصوصیِ کودک) | کد | +| ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد | +| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور | +| ۹ | کشِ مشترک (Redis) نیست؛ محدودیت‌ها per-worker‌اند | متوسط | `.env` سرور | +| ۱۰ | ورودِ مهمان فقط با `deviceId` است | کم (طراحی) | کد | +| ۱۱ | توکنِ refresh و کدِ OTP بی‌هش در دیتابیس | کم | کد | +| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ حسابِ مهمان قابلِ بالا بردن است | کم (مالی) | کد | +| ۱۳ | محدودیتِ قلب در سرور خاموش است (`HEARTS_ENFORCED=0`) | کم (کسب‌وکار) | `.env` + فرانت | +| ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت | +| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور | +| ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت | + +--- + +## ۱. کلیدِ امضای توکن (`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` دیگر هشدارِ «شماره‌های تستِ ورود با کدِ ثابت فعال‌اند» ندارد. + +## ۳. داده‌ی دمو و مهمانِ عمومی + +- **چیست:** کاربرهای ۶ (`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://api.math.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/` باید ۴۰۱/۴۰۳ بدهد. + +## ۶. جدولِ رتبه‌بندیِ عمومی — حریمِ خصوصیِ کودک + +- **چیست:** `GET /api/leaderboard` بدونِ توکن (تأیید: HTTP 200) برای هر ردیف `rank`، + `userId`، `name` و `xpInPeriod` می‌دهد. +- **خطر:** نامِ کودکان و شناسه‌ی داخلی‌شان برای همه‌ی اینترنت قابلِ جمع‌آوری است. + پاک‌سازیِ نام (حذفِ شماره و لینک، `clean_display_name`) فقط راهِ تماس را می‌گیرد، نه خودِ نام را. +- **رفع:** `IsAuthenticated`؛ حذفِ `userId` از پاسخ (جز برای ردیفِ خودِ کاربر)؛ نمایشِ + نامِ کوچک یا نامِ مستعار. + +## ۷. پنلِ ادمین روی دامنه‌ی عمومی + +- **چیست:** `/admin/` روی `api.math.tadrisino2.ir`، با ورودِ نام‌کاربری/رمزِ جنگو. از + ۲۰۲۶-۰۹-۲۵ سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)، + ولی: شمارش per-worker است (مورد ۹)، IP با XFF جعل‌پذیر است (مورد ۸)، و ۲FA ندارد. +- **خطر:** ادمین همه‌ی محتوا، پلن‌ها، کدهای تخفیف، سفارش‌ها و کاربرها را کنترل می‌کند. +- **رفع:** محدود کردنِ `/admin/` در nginx به IPهای تیم یا VPN؛ تغییرِ مسیرِ پیش‌فرض؛ ۲FA؛ + رمزهای قوی و حسابِ staffِ جدا برای هر نفر. + +## ۸. دسترسیِ مستقیم به پورتِ ۸۰ (دور زدنِ ArvanCloud) + +- **چیست:** `http://78.38.35.249/api/health` با `Host: api.math.tadrisino2.ir` پاسخ می‌دهد + (تأیید: HTTP 200). `NUM_PROXIES=2` فرض می‌کند همیشه دو پروکسی جلوی ماست. +- **خطر:** کسی که IP را بداند `X-Forwarded-For` را جعل می‌کند و محدودیت‌های IP-محور + (مهمان، OTP، ادمین) را دور می‌زند. +- **رفع (ادمینِ سرور):** پورتِ ۸۰/۴۴۳ فقط برای رنج‌های ArvanCloud، یا `set_real_ip_from` + + `real_ip_header` در nginx. + +## ۹. نبودِ کشِ مشترک (`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`ِ خام. + +## ۱۱. داده‌ی حساس بی‌هش در دیتابیس + +- **چیست:** `Session.refresh_token` و `OTPCode.code` به‌صورتِ متنِ ساده ذخیره می‌شوند + (`accounts/models.py`) و در پنلِ ادمین (`Session`, `OTPCode`) دیده می‌شوند. +- **خطر:** هر کس بکاپِ دیتابیس یا پنلِ ادمین را ببیند نشستِ هر کاربر را می‌رباید. + بکاپ‌ها در `.deploy-backups/` کنارِ خودِ پروژه روی یک ماشینِ مشترک‌اند. +- **رفع:** ذخیره‌ی هشِ SHA-256 و مقایسه با هش؛ حذفِ این دو فیلد از نمایشِ ادمین. + +## ۱۲. هزینه‌ی دستیارِ AI + +- **چیست:** سقفِ روزانه ۱۵ سؤال **برای هر کاربر** (`DAILY_AI_QUOTA`) و ۲۰ در ساعت؛ مهمان‌ها + بر اساسِ IP محدود می‌شوند ولی هر IP ساعتی ۲۰ حسابِ مهمانِ تازه می‌سازد (`guest_session`)، + و هر حسابِ تازه ۱۵ سؤالِ تازه دارد. طولِ سؤال از ۲۰۲۶-۰۹-۲۵ حداکثر ۲۰۰۰ نویسه است. +- **خطر:** با چند IP، اعتبارِ GapGPT در چند ساعت تمام می‌شود (اعتبار یک بار در ۲۰۲۶-۰۹-۲۵ + به صفر رسید، البته به دلیلِ دیگر). +- **رفع:** سقفِ سراسریِ روزانه برای کلِ اپ؛ دستیار فقط برای کاربرِ واردشده با شماره یا + با سقفِ بسیار کمتر برای مهمان؛ هشدار وقتی `aiProvider.state` در `/api/health` برابرِ + `quota_exhausted` شد. + +## ۱۳. محدودیتِ قلب خاموش است + +- **چیست:** با صفر قلب، سرور همچنان جواب را می‌پذیرد، مگر `HEARTS_ENFORCED=1` + (`content/views.py::QuestionAnswerView`). «قلبِ نامحدود» تنها مزیتِ اشتراک است. +- **خطر:** مزیتِ پولی فقط در ظاهرِ اپ اعمال می‌شود؛ با صدا زدنِ مستقیمِ API دور زده می‌شود. +- **چرا باز مانده:** فرانت برای ۴۰۳ِ `no_hearts` هنوز صفحه ندارد؛ روشن کردنش دانش‌آموز را به + صفحه‌ی خطا می‌برد. رفتار با صفر قلب هم تصمیمِ محصولیِ باز است. +- **رفع:** بعد از صفحه‌ی «قلب‌ها تمام شد» در فرانت، `HEARTS_ENFORCED=1` در `.env`. + +## ۱۴. دیتابیس و بکاپ + +- **چیست:** SQLite در `~/mathapp-v2/db.sqlite3`، روی دیسکی که بینِ چند سرویس مشترک است و + ~۸۳٪ پر است. `transaction_mode: IMMEDIATE` هم‌زمانی را درست ولی سریالی می‌کند. بکاپ دستی + است (`deploy.sh` پیش از `migrate` بکاپ نمی‌گیرد) و در همان پوشه می‌ماند. +- **خطر:** از دست رفتنِ کلِ داده با یک خطای دیسک یا یک دستورِ اشتباه؛ پُر شدنِ دیسک + سرویس‌های دیگر (Gitea، ایمیل) را هم می‌خواباند. +- **رفع:** مهاجرت به Postgres (روی همان ماشین نصب است)، بکاپِ خودکار پیش از هر `migrate`، و + نگه‌داریِ بکاپ بیرون از ماشین. + +## ۱۵. دسترسیِ سرور + +- **چیست:** ورود با رمز به حسابِ مشترکِ `administrator` با `sudo (ALL : ALL) ALL` و عضوِ + `docker` و `lxd`، روی ماشینی که Gitea و ایمیلِ شرکت هم رویش است. رمز یک بار در گفتگو با + agent گذاشته شده و روی دسکتاپِ صاحبِ پروژه در یک فایلِ متنی نگه داشته می‌شود. +- **خطر:** هر نشتِ رمز یعنی دسترسیِ root به کلِ ماشینِ شرکت، نه فقط این پروژه. +- **رفع (ادمینِ سرور):** کلیدِ SSH به‌جای رمز، تعویضِ رمز، حسابِ جدا برای این پروژه با + دسترسیِ فقط به `~/mathapp-v2` و یک خطِ `NOPASSWD` فقط برای + `systemctl restart zareian-mathapp`؛ پاک کردنِ فایلِ رمز از دسکتاپ (Keychain جایگزین است). + +## ۱۶. نگه‌داریِ توکن در فرانت + +- **چیست:** کلاینتِ فرانت (`src/api/index.ts` در مخزنِ فرانت) توکن و refresh را در + `localStorage` نگه می‌دارد. +- **خطر:** هر XSS در سایت هر دو را می‌خواند. +- **رفع:** CSPِ سخت‌گیرانه روی `math.tadrisino2.ir`؛ در بلندمدت refresh در کوکیِ `HttpOnly`. + +--- + +## چیزهایی که بسته شده‌اند (برای اینکه دوباره باز نشوند) + +در بازبینیِ ۲۰۲۶-۰۹-۲۵ (کامیتِ `42783f9`) بسته شدند؛ جزئیات در `docs/architecture.md`: +پلنِ هر خرید روی سفارش، دست نخوردنِ اشتراکِ فعال هنگامِ خرید، یک کدِ تخفیف پیش از درگاه، +حذفِ `/api/payment/webhook`، امتیاز فقط از جوابِ ثبت‌شده، OTPِ ۶ رقمی با ۵ حدس، خروج از حساب، +حفظِ مهمان هنگامِ ثبت‌نام، `select_for_update` مؤثر با `IMMEDIATE`، سقفِ ورودِ ادمین، سقفِ +طولِ سؤالِ AI، مقایسه‌ی زمان‌ثابتِ `X-Internal-Key`. + +هر تغییری در این مسیرها باید تست‌های `payment/tests_audit_fixes.py` و +`accounts/tests_audit_fixes.py` را سبز نگه دارد.