Server hardening: nginx origin locked to ArvanCloud + CSP, demo logins removed, docs

- ops/nginx/: the two nginx sites as they now run on 249. Only ArvanCloud
  edges reach the app (direct hits on the IP get 403, so Arvan's DDoS/WAF
  can't be skipped); the frontend gets CSP (own sources only, the one inline
  script by hash), X-Frame-Options, nosniff, Referrer-Policy,
  Permissions-Policy, HSTS; API body limit 100M -> 20M plus HSTS. Checked from
  outside and in Chrome (all tabs and an island quiz, logged in): no CSP errors.
- Demo accounts (users 11-15, 25) and TEST_LOGIN_PHONES removed from the server;
  the health warning for fixed-code logins now has a test and says how to
  test instead (an OTPCode made in the admin).
- SMS daily cap on the server is 2000 (launch of ~600 users).
- CLAUDE.md: never run tests with --parallel on macOS, always --noinput
  (both hang forever without an error).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 19:17:11 +03:30
parent 2a03dc81b5
commit ba4ecff29e
9 changed files with 196 additions and 117 deletions

View File

@@ -70,8 +70,8 @@ paid on the server on 2026-10-03); orders without the `question_credits`/`hearts
## Before any real user: read `docs/security-before-production.md`
The server is deliberately left in demo mode for the internship presentation: seven
fixed-code demo logins exist, demo users sit in the production DB, and the leaderboard and `/admin/` are public (`/api/health` shows only ok/error without `X-Internal-Key`; Swagger/ReDoc/schema need an admin session since 2026-10-08). (The token signing key is no longer the
Since 2026-10-13 there are no fixed-code logins and no demo users on the server (`TEST_LOGIN_PHONES`
empty; users 11–15 and 25 deleted; test logins are an `OTPCode` made in the admin). Still open: the leaderboard and `/admin/` are public (`/api/health` shows only ok/error without `X-Internal-Key`; Swagger/ReDoc/schema need an admin session since 2026-10-08). (The token signing key is no longer the
committed one: replaced 2026-10-07, see the status block below.) 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 when one is closed, move it to that file's «حفاظ‌هایی که هست» table with the test that holds it (the date lives in the commit message; the owner wants docs to describe the current state only).
@@ -82,11 +82,20 @@ and when one is closed, move it to that file's «حفاظ‌هایی که هست
source venv/bin/activate
python manage.py runserver
python manage.py migrate
python manage.py test # all tests
python manage.py test accounts # single app
python manage.py test accounts.tests.SomeTestCase.test_something # single test
python manage.py test --noinput # all tests (~60 s, serial)
python manage.py test --noinput accounts # single app
python manage.py test --noinput accounts.tests.SomeTestCase.test_something # single test
```
**Never run the suite with `--parallel` on this Mac, and always pass `--noinput`.** Both hang forever
without an error (2026-10-13, cost 30+ minutes): the test DB is a SQLite *file* (`.test-db.sqlite3`,
needed by the real-concurrency tests), and on macOS Django's spawned parallel workers fail with
`unable to open database file` while the parent waits for them indefinitely. Separately, a test DB left
behind by a killed run makes Django ask «Type 'yes' to delete the old test database» on stdin; in a
background shell nobody answers and it waits forever. `--noinput` deletes it. Serial runs take ~60 s
for the whole suite, so there is nothing to gain. If a run prints nothing for more than ~3 minutes it
is stuck, not slow: kill it (`pkill -f "manage.py test"`) and check the log for the two causes above.
Local dev requires `DJANGO_DEBUG=1` (enables Swagger auth via `DEV_MASTER_OTP`/`devOtp` in responses). `DEBUG` defaults to `False`. Production also requires `DJANGO_SECRET_KEY`, `DJANGO_ALLOWED_HOSTS` (comma-separated), and ideally `CACHE_URL` (Redis). Without it the app still boots on per-process `LocMemCache` and `/api/health` warns — throttles and admin-driven cache invalidation are then per worker. On the server since 2026-10-08 it points at our own Redis (`zareian-redis.service`, port 6390, localhost, password-protected, 256 MB `allkeys-lru`, no persistence), never the company's Redis on 6379. Its outage is logged and rate limits stop applying (fail-open); not tested under real Redis failure on the server.
No pytest; uses Django's built-in test runner. No linter/formatter configured.
@@ -451,6 +460,22 @@ preferred setup, because there is then no key to leak. **Never let the server ru
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
anyone who can read this repo can forge a token for any user.**
## بازبینیِ پروداکشن و سخت‌کردنِ سرور — ۲۰۲۶-۱۰-۱۳ (بک‌اند `e3bddb3` و بعد)
چک‌لیستِ سینیور: `~/Desktop/گزارشات Tadrisino/گزارش-۳۴/` (HTML و PDF؛ ساز در scratchpad، نه ریپو).
- **جعلِ IP بسته شد:** `config.middleware.TrustedProxyMiddleware` فقط XFFِ رسیده از رنج‌های آروان را می‌پذیرد
(`TRUSTED_PROXY_NETWORKS`؛ X-Real-IP را nginx می‌گذارد). پیش از آن درخواستِ مستقیم به IP با XFFِ چرخان همه‌ی سقف‌های
IP-محور را دور می‌زد (روی سرور بازتولید شد).
- **nginx (دو سایتِ ما، نسخه در `ops/nginx/`)**: فقط IPهای آروان (مستقیم ۴۰۳)، CSP و هدرهای امنیتی روی فرانت، سقفِ بدنه‌ی
API ۲۰ مگابایت، HSTS. بکاپِ قبلی: `.deploy-backups/nginx-*-before-20261013-nginx-hardening`. سنجیده: از بیرون، و اپ در Chrome
(همه‌ی تب‌ها و آزمونِ جزیره، با ورود) بی هیچ خطای CSP. نسنجیده: WebViewِ اندروید و `pay.html` تا درگاه.
- **حساب‌های دمو پاک شدند** (۱۱–۱۵، ۲۵) و `TEST_LOGIN_PHONES` خالی؛ بکاپ `.deploy-backups/db-before-demo-removal-20261013-190632.pgdump`
و `env-before-demo-removal-20261013-190632`. سقفِ پیامکِ روزانه ۲٬۰۰۰. اعتبارِ کاوه‌نگار آن روز ۲۵۳٬۸۴۱ ریال (شارژ: سینیور).
- **CPUِ ماشین:** `metabase` و `celerybeat`ِ Artina (مالِ دیگران) بی‌وقفه ری‌استارت می‌شوند (۱۷۶ و ~۱۲۰۰ بار در ساعت) و
حدودِ ۳ از ۸ هسته را می‌گیرند. به آن‌ها دست نزن؛ درخواست از ادمینِ سرور.
- `/etc/nginx` را classifierِ حالتِ خودکار فقط با تأییدِ صریحِ مالک و گام‌به‌گام (بکاپ → نصب و `nginx -t` → reload) اجازه داد.
## کافه‌بازار: اشتراک، تخفیفِ پویا، تمدیدِ خودکار — روی سرور از ۲۰۲۶-۱۰-۱۲ ۱۷:۵۴ (بک‌اند `8077ab4`، فرانت `fffdeac`)
- **محصول‌های پیشخان** (پرسشِ API با توکنِ ساختگی، ۱۰۰۰۰–۱۰۶۹۹): فقط `10342` (یک‌ماهه، تأییدِ سینیور)، `10343` (۹۰ روزه)،
@@ -535,8 +560,8 @@ anyone who can read this repo can forge a token for any user.**
۱۱۲ درخواست در ثانیه، p99 ۰٫۶۷ ثانیه، وقتی CPUِ ماشین ~۸۰٪ مشغول بود. ثبتِ جواب ۱۵٫۴→۱۱ ms، `/api/home` ۱۲٫۴→۸٫۶ ms.
- **برآورد (نه سنجیده):** ۱۰۰۰ تا ۱۵۰۰ بازیکنِ هم‌زمان (فرض: یک درخواست در ۸ ثانیه)، ۴ تا ۶ هزار آنلاین. سقفِ واقعی CPUِ
مشترک است، نه حافظه یا دیتابیس؛ worker بیشتر روی همین ماشین ظرفیت نمی‌سازد، CPUِ دیگران را می‌گیرد.
- **سقف‌های تنظیمی:** پیامک ۳۰۰ در روز برای کلِ سامانه و دستیار ۳۰۰۰ سوال در روز — هر دو برای روزِ لانچ کم‌اند و
**تصمیمِ مالک/سینیور** است (`OTP_SMS_GLOBAL_DAILY_MAX`، `AI_GLOBAL_DAILY_MAX` در `.env`؛ هیچ‌کدام الان در `.env` نیست).
- **سقف‌های تنظیمی:** پیامکِ کلِ سامانه روی سرور ۲٬۰۰۰ در روز (`OTP_SMS_GLOBAL_DAILY_MAX` در `.env`، از ۲۰۲۶-۱۰-۱۳ به
تصمیمِ مالک برای لانچِ ۶۰۰ نفره؛ پیش‌فرضِ کد ۳۰۰) و دستیار ۳۰۰۰ سوال در روز (`AI_GLOBAL_DAILY_MAX`، در `.env` نیست).
هر IP: ۱۰۰ درخواستِ کد و ۳۰۰ ثبتِ کد در ساعت (تصمیمِ مالک، برای کلاس روی یک وای‌فای).
- **پیشنهادشده، ساخته نشده:** جلسه‌ی آزمون به سبکِ دولینگو (سوال و کلید با هم دانلود، سنجش روی گوشی، ارسالِ یک‌جا
و سنجشِ دوباره در سرور) بارِ بازی را حدوداً نصف می‌کند؛ کلید روی گوشی می‌رود، پس تصمیمِ محصولی است. کلِ محتوا
@@ -1013,10 +1038,8 @@ migrationها اعمال شدند، سه‌یار و `nextLessonId` از بیر
سه چیزِ کوچک که در همان روز اضافه شد و دانستنشان لازم است:
- **`TEST_LOGIN_PHONES`** — فهرستِ «شماره:کد» در `.env` که ورود بدونِ کاوه‌نگار را
ممکن می‌کند. از ۲۰۲۶-۰۹-۲۳ شب، به تصمیمِ صاحبِ پروژه، روی سرور **۷ شماره‌ی دمو**
دارد (`09120000101` تا `09120000107`). کدها فقط در راهنمای ارائه‌ی خودِ صاحبِ پروژه‌اند،
نه در ریپو. جزئیات در `docs/operations.md`.
- **`TEST_LOGIN_PHONES`** — فهرستِ «شماره:کد» برای ورود بی‌پیامک. **از ۲۰۲۶-۱۰-۱۳ روی سرور خالی است**
(تصمیمِ مالک: کدِ ثابت نکته‌ی امنیتی است) و حساب‌های دمو پاک شدند؛ `docs/operations.md` «ورود بی‌پیامک».
- **RAG روی سرور روشن است** (همان شب): `OPENAI_API_KEY`/`QDRANT_API_KEY` در `.env`
سرور، `/api/health` می‌دهد `qdrant: true, openai: true`. هر سؤالِ کاربرِ واقعی از
اعتبارِ GapGPT خرج می‌کند؛ سقفِ ۱۵ در روز برای هر کاربر آن را محدود نگه می‌دارد.

View File

@@ -316,8 +316,8 @@ def _warnings() -> list[str]:
# تعداد عمداً گفته نمی‌شود: این صفحه عمومی است و «۷ حسابِ کدِ ثابت» برای
# مهاجم یعنی هدفِ مشخص برای حدس زدن.
out.append(
'شماره‌های تستِ ورود با کدِ ثابت فعال‌اند (TEST_LOGIN_PHONES). '
'برای توسعه لازم است؛ پیش از تحویلِ نهایی به کاربرانِ واقعی حذفشان کنید.'
'شماره‌های تستِ ورود با کدِ ثابت فعال‌اند (TEST_LOGIN_PHONES). روی سرور باید خالی باشد: '
'هر کس کد را دیده باشد وارد می‌شود. برای آزمون یک OTPCode در پنلِ ادمین بسازید.'
)
if not settings.DEBUG and settings.SECRET_KEY.startswith('django-insecure'):
# بزرگ‌ترین ریسکِ باز (docs/security-before-production.md): این کلید در گیت است و هر

View File

@@ -0,0 +1,20 @@
"""
روی سرور هیچ شماره‌ای با کدِ ورودِ ثابت نباید باشد (حساب‌های دمو ۲۰۲۶-۱۰-۱۳ پاک شدند). اگر کسی
TEST_LOGIN_PHONES را دوباره پر کند، /api/health (با کلید) باید بگوید، نه بی‌صدا بپذیرد.
"""
from django.test import TestCase, override_settings
@override_settings(INTERNAL_SERVICE_KEY='k')
class FixedCodeLoginsAreFlaggedTests(TestCase):
def _warnings(self):
return self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()['warnings']
@override_settings(TEST_LOGIN_PHONES={'09120000001': '834512'})
def test_a_fixed_code_phone_raises_a_warning(self):
self.assertTrue(any('TEST_LOGIN_PHONES' in w for w in self._warnings()))
@override_settings(TEST_LOGIN_PHONES={})
def test_empty_list_is_quiet(self):
self.assertFalse(any('TEST_LOGIN_PHONES' in w for w in self._warnings()))

View File

@@ -220,7 +220,7 @@ curl https://mathapi.tadrisino2.ir/api/health
| `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `FRONTEND_URL/?orderId=…&status=paid\|failed` برمی‌گردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی |
| `BAZAAR_PISHKHAN_API_SECRET` (روشِ جدید) یا `BAZAAR_CLIENT_ID` · `BAZAAR_CLIENT_SECRET` (روشِ قدیمی)، به‌علاوه‌ی `BAZAAR_PACKAGE_NAME` | `POST /api/payment/bazaar/verify` هر خرید را نامعتبر می‌داند (fail-closed)؛ `/api/health` → `bazaar: false` |
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را می‌دهد |
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |
| `TEST_LOGIN_PHONES` | خالی، و روی سرور باید خالی بماند (بخشِ «ورود بی‌پیامک») |
| `HEARTS_ENFORCED` | روشن (از ۲۰۲۶-۱۰-۰۲): با صفر قلب نه جواب پذیرفته می‌شود نه پایانِ درس/جزیره‌ی بی‌جواب. `0` خاموشش می‌کند |
| `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بسته‌اند (در DEBUG هم؛ پیش‌فرض ندارد) |
@@ -281,7 +281,9 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
4. **فرانت:** روی سرورِ فرانت در `dist/config.js` بنویسید
`apiBaseUrl: "https://<دامنه‌ی تازه‌ی API>/api"` (یا `.env.production` را عوض و
build کنید)، و کشِ آروان را برای `config.js` و `index.html` پاک کنید.
5. **وارسی:** `curl https://<دامنه‌ی تازه‌ی API>/api/health` (در `clientIp` باید IPِ
5. **nginx و CSP:** در `ops/nginx/zareian-front-headers.conf` دامنه‌ی تازه‌ی API را در `connect-src` بگذارید،
روی سرور در `/etc/nginx/snippets/` نصب کنید (بخشِ «nginx»)؛ وگرنه مرورگر هر درخواستِ API را می‌بندد.
6. **وارسی:** `curl https://<دامنه‌ی تازه‌ی API>/api/health` (در `clientIp` باید IPِ
خودتان باشد) و یک بار ورود و باز کردنِ یک درس در اپ.
مرحله‌ی ۳ را فقط یک پرداختِ واقعی ثابت می‌کند؛ تا آن انجام نشده، پرداخت را
@@ -344,53 +346,19 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
تست‌ها: `payment/tests_bazaar_subscription.py`، `payment/tests_bazaar_oauth.py`.
**هیچ خریدِ واقعی‌ای از بازار انجام نشده**؛ نه پذیرفتنِ JWT از سوی بازار و نه تمدیدِ واقعی.
## ورودِ توسعه‌دهنده‌ها (بدونِ سرویسِ پیامک)
## ورود بی‌پیامک (آزمون)
تا وقتی `KAVENEGAR_API_KEY` ست نشده، با `DEBUG=0` هیچ‌کس نمی‌تواند با شماره
وارد شود. برای اینکه ورودِ تیم به یک نفرِ خاص وابسته نباشد، چند شماره‌ی تست با
کدِ ثابت تعریف کنید:
**روی سرور هیچ شماره‌ای با کدِ ثابت نیست** و نباید باشد: کدِ ثابت هر کس آن را در ارائه یا اسکرین‌شات
دیده باشد را وارد می‌کند. `TEST_LOGIN_PHONES` در `.env` سرور خالی است و `/api/health` اگر پر شود هشدار
می‌دهد. حساب‌های دمو (کاربرهای ۱۱ تا ۱۵ و ۲۵) و همه‌ی داده‌شان با `delete_users` پاک شدند (بکاپِ پیش از آن:
`.deploy-backups/db-before-demo-removal-20261013-190632.pgdump`). حسابِ ۱۰ به شماره‌ی مالکِ پروژه وصل است و ماند.
```bash
TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
```
راهِ آزمون بی‌پیامک: یک ابرکاربر در `/admin/accounts/otpcode/add/` برای شماره‌ی خودش یک کدِ تصادفی با انقضای
چند دقیقه می‌سازد (یک‌بارمصرف، با همان سقفِ ۵ حدس). کارمندِ عادی این صفحه را ندارد
(`StaffCannotMintLoginsTests`). بعد از آزمون `POST /api/auth/logout`.
برای این شماره‌ها پیامکی ارسال نمی‌شود و همان کد همیشه معتبر است. هر شماره‌ی
دیگری مسیرِ عادیِ OTP را می‌رود، بی‌هیچ تغییری.
```bash
curl -X POST https://mathapi.tadrisino2.ir/api/auth/otp/request \
-H 'Content-Type: application/json' -d '{"phone":"09120000001"}'
curl -X POST https://mathapi.tadrisino2.ir/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"phone":"09120000001","otp":"834512"}'
```
این همان الگویی‌ست که Firebase Auth (با نامِ fictional phone numbers) و Apple
(حسابِ دمو برای بررسیِ App Store) برای همین مسئله دارند.
- **با `DEV_MASTER_OTP` یکی نیست.** آن کدِ `0000` **هر** شماره‌ای را می‌پذیرد و
فقط با `DEBUG=1` روشن می‌شود. این فقط همین چند شماره را، هرکدام با کدِ خودش.
- **کد در پاسخِ API برنمی‌گردد**، پس دانستنِ شماره برای ورود کافی نیست. سقفِ
تلاشِ ورودِ هر شماره روی این‌ها هم اعمال می‌شود، پس حدسِ کد بسته است.
- **مقدارِ نامعتبر سرویس را بالا نمی‌آورد** و پیامِ صریح می‌دهد — بی‌صدا رد
نمی‌شود. کد باید ۴ تا ۶ رقم باشد (`LoginRequestSerializer` سقفِ ۶ کاراکتر
دارد).
- **`/api/health` تا وقتی پر باشد هشدار می‌دهد** — فقط تعداد، نه شماره و نه کد.
- شماره را **دقیقاً** همان‌طور بنویسید که کلاینت می‌فرستد؛ این کدبیس شماره را
normalize نمی‌کند، پس `09121111111` و `+989121111111` دو کاربرِ جدا هستند.
- کدِ تصادفی بگذارید، نه `1234`، و پیش از باز شدنِ سرویس روی کاربرانِ واقعی
خالی‌اش کنید.
### حسابِ نمایشی
حسابِ «علی رضایی» (کاربرِ ۱۰) پُر از داده است و با شماره‌ی مالکِ پروژه وصل می‌شود؛ بقیه‌ی دموها با `TEST_LOGIN_PHONES` وارد می‌شوند.
⚠️ **این داده دستی روی دیتابیسِ پروداکشن نوشته شده و در گیت نیست.** پیش از باز
شدنِ سرویس روی کاربرانِ واقعی باید برود، وگرنه اعلان‌ها و لیدربردِ ساختگی را
کاربرِ واقعی می‌بیند. `purge_demo_data` این‌ها را **نمی‌گیرد** — آن فرمان فقط
دنبالِ پیشوندِ `[SEED]`/`[DEMO]` روی Grade/Plan/Character/User می‌گردد. پاک
کردن یعنی حذفِ دستیِ ردیف‌های `Notification` و `LeaderboardEntry` از پنلِ
ادمین (اول بکاپ، بخشِ بالا).
روی لپ‌تاپ (`DEBUG=1`) کدِ `devOtp` در پاسخ می‌آید و `TEST_LOGIN_PHONES=09120000001:834512` هم کار می‌کند؛
هیچ‌کدام برای سرور نیست.
## سرور
@@ -422,6 +390,29 @@ sudo systemctl restart gunicorn # مالِ ما نیست — اپِ
5. **`df -h /` را قبل از هر کارِ پرنوشت چک کنید.** دیسک مدتی‌ست حدودِ ۹۴٪ پر
است؛ پر شدنش سرویسِ ایمیل و Gitea را هم با ما پایین می‌کشد.
## nginx (دو سایتِ ما)
نسخه‌ی اصلی در ریپو است: `ops/nginx/`. روی ۲۴۹: `sites-enabled/mathapp` (API، فایلِ معمولی)،
`sites-available/mathapp-front` (فرانت)، و دو snippet در `/etc/nginx/snippets/`:
| فایل | چه می‌کند |
|---|---|
| `zareian-arvan-only.conf` | فقط IPهای ArvanCloud و خودِ ماشین؛ درخواستِ مستقیم به IPِ سرور ۴۰۳. همان فهرستِ `TRUSTED_PROXY_NETWORKS` |
| `zareian-front-headers.conf` | CSP، `X-Frame-Options`، `nosniff`، `Referrer-Policy`، `Permissions-Policy`، HSTS برای فرانت |
| `mathapp` | سقفِ بدنه ۲۰ مگابایت، HSTS |
تغییر: فایلِ ریپو را ویرایش و کامیت کنید؛ روی سرور فایلِ فعلی را در `.deploy-backups/` کپی کنید، `sudo install -m 644`،
`sudo nginx -t`، و فقط اگر موفق بود `sudo systemctl reload nginx` (نه restart: سایت‌های دیگران روی همین nginx‌اند).
بعد از بیرون: هدرها با `curl -sI`، و درخواستِ مستقیم به IP باید ۴۰۳ بدهد.
**وقتی دیگران به این‌ها بخورند:**
- آروان رنجِ IPِ تازه گرفته: کاربرانِ پشتِ آن edge ۴۰۳ می‌گیرند و peerشان در `/var/log/nginx/access.log` است. فهرست را
از <https://www.arvancloud.ir/fa/ips.txt> در snippet و در `config/settings.py` با هم تازه کنید.
- اسکریپتِ درون‌خطیِ `index.html`ِ فرانت عوض شده: CSP آن را می‌بندد (کنسول: «Refused to execute inline script»). هشِ تازه را در
snippet بگذارید (روش در خودِ فایل).
- سنجیده (۲۰۲۶-۱۰-۱۳): اپ در Chrome، بی‌ورود و با ورود، همه‌ی تب‌ها و آزمونِ یک جزیره، بی هیچ خطای CSP. **نسنجیده:** اپِ اندروید
(WebView همان CSP را می‌گیرد) و صفحه‌ی `pay.html` تا درگاه.
## لاگ‌ها
هر درخواستِ `/api/` به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌شود

View File

@@ -1,6 +1,6 @@
# پیش از پروداکشنِ واقعی: چیزهایی که عمداً باز مانده‌اند
**آخرین بازبینی با کد: ۲۰۲۶-۱۰-۰۷ (۱۵ مهر ۱۴۰۵)؛ با سرورِ زنده: ۲۰۲۶-۱۰-۰۴ برای بقیه‌ی بندها؛ آنچه ۱۰-۰۷ دیپلو شد از بیرون و با آزمونِ زنده سنجیده شد: کلیدِ امضا، سقفِ نرخ، پایانِ جزیره/درس، سلامت.**
**آخرین بازبینی با کد و سرورِ زنده: ۲۰۲۶-۱۰-۱۳ (۲۱ مهر ۱۴۰۵): حساب‌های دمو، nginx (فقط آروان، CSP)، جعلِ IP، سقفِ پیامک.**
این پروژه تا این تاریخ در حالتِ **دمو و ارائه‌ی کارآموزی** است، نه محصولِ نهایی. چند چیز
عمداً باز گذاشته شده تا توسعه‌دهنده‌ها، فرانت‌کار و ارائه کار کنند. هر کدام برای یک سرورِ
@@ -21,16 +21,12 @@
| # | مورد | خطر | کارِ کیست |
|---|---|---|---|
| ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور |
| ۳ | داده‌ی دمو روی دیتابیسِ پروداکشن | بالا | دیتابیسِ سرور |
| ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد |
| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور |
| ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد |
| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ شماره‌ی تازه (هر شماره ۲ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
| ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت |
| ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت |
| ۲۴ | سقفِ روزانه‌ی ۳۰۰ پیامکِ کلِ سامانه را یک ناشناس پر می‌کند و ورود با پیامک برای همه بسته می‌شود | بالا (دسترس‌پذیری، هزینه) | تصمیمِ طراحی |
| ۲۴ | سقفِ روزانه‌ی پیامکِ کلِ سامانه (۲٬۰۰۰ روی سرور) را یک ناشناس پر می‌کند و ورود با پیامک برای همه بسته می‌شود | بالا (دسترس‌پذیری، هزینه) | تصمیمِ طراحی |
| ۲۵ | با دانستنِ شماره‌ی کسی، ورودِ او تا ۲۴ ساعت بسته می‌شود | متوسط | تصمیمِ طراحی |
| ۲۷ | کلیدِ جوابِ هر سوال با یک حسابِ دوم درمی‌آید؛ امتیازِ هر جوابِ درست دو بار داده می‌شود | کم | تصمیمِ محصولی |
| ۲۸ | استفاده‌ی دوباره از توکنِ تمدیدِ دزدیده‌شده تشخیص داده نمی‌شود؛ «خروج از همه‌ی دستگاه‌ها» نیست | کم | کد، بعد از ارائه |
@@ -39,51 +35,14 @@
---
## ۲. حساب‌های دمو با کدِ ورودِ ثابت (`TEST_LOGIN_PHONES`)
- **چیست:** ۷ شماره‌ی `09120000101` تا `09120000107` با کدِ ثابت در `.env` سرور. شماره‌ها
در ریپو (`docs/operations.md`، `CLAUDE.md`) آمده‌اند؛ کدها فقط در راهنمای ارائه‌ی صاحبِ
پروژه‌اند.
- **خطر:** کسی که کد را از راهنما، اسکرین‌شات یا ارائه ببیند وارد آن حساب‌ها می‌شود. این
کدها با `OTP_MAX_FAILED_ATTEMPTS` باطل نمی‌شوند (ردیفِ `OTPCode` ندارند)؛ فقط سقفِ
per-workerِ تلاشِ ورود جلویشان است.
- **رفع:** `TEST_LOGIN_PHONES` را از `.env` سرور پاک کنید و ری‌استارت کنید.
- **چطور بسنجید:** `/api/health` دیگر هشدارِ «شماره‌های تستِ ورود با کدِ ثابت فعال‌اند» ندارد.
## ۳. داده‌ی دمو روی دیتابیسِ پروداکشن
- **چیست:** کاربرهای ۱۰ تا ۱۵ و ۲۵ (حساب‌های دمو با کدِ ثابت، بند ۲) با درس، گفتگو، تیکت و
سفارش روی دیتابیسِ پروداکشن ساخته شده‌اند. حسابِ ۱۰ («علی رضایی»، با ۷ سفارش) حسابِ نمایشیِ اصلی است.
- **خطر:** داده‌ی ساختگی در آمار و جدولِ رتبه‌بندیِ واقعی.
- **رفع:** پیش از کاربرِ واقعی با `delete_users --ids … --yes` (پیش از آن بکاپ) پاک شوند.
`purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ واقعیِ viewها ساخته شده‌اند).
- **چطور بسنجید:** `/api/health` → `traffic.users` فقط کاربرانِ واقعی را بشمارد.
## ۷. پنلِ ادمین روی دامنه‌ی عمومی
- **چیست:** `/admin/` روی `mathapi.tadrisino2.ir`، با ورودِ نام‌کاربری/رمزِ جنگو. سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)،
ولی: IP با XFF جعل‌پذیر است (مورد ۸)، و ۲FA ندارد.
IP جعل‌پذیر نیست (فقط از آروان)، ولی ۲FA و محدودیتِ IP ندارد.
- **خطر:** ادمین همه‌ی محتوا، پلن‌ها، کدهای تخفیف، سفارش‌ها و کاربرها را کنترل می‌کند.
- **رفع:** محدود کردنِ `/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.
- **دور زدنِ سقف‌ها بسته شد:** `TrustedProxyMiddleware` فقط XFFِ رسیده از رنج‌های آروان را می‌پذیرد (جدولِ حفاظ‌ها).
آنچه مانده: سپرِ DDoS/WAFِ آروان برای درخواستِ مستقیم به IP وجود ندارد.
- **بازتولید شد ۲۰۲۶-۱۰-۰۴:** `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 که در دیتابیس شمرده می‌شوند متأثر نیستند. پورت‌های ۸۰ و ۴۴۳ هر دو باز بودند.
## ۱۱. کدِ OTP بی‌هش در دیتابیس
- **چیست:** `OTPCode.code` متنِ ساده است و در فهرستِ پنلِ ادمین (`OTPCodeAdmin`) دیده می‌شود.
@@ -121,23 +80,13 @@
- **چیست:** کلاینتِ فرانت (`src/api/index.ts` در مخزنِ فرانت) توکن و refresh را در
`localStorage` نگه می‌دارد.
- **خطر:** هر XSS در سایت هر دو را می‌خواند.
- **رفع:** CSPِ سخت‌گیرانه روی `math.tadrisino2.ir`؛ در بلندمدت refresh در کوکیِ `HttpOnly`.
## ۲۲. هدرهای امنیتیِ فرانت
- **چیست:** `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`.
- **رفع:** CSPِ سخت‌گیرانه روی `math.tadrisino2.ir` حالا هست (جدولِ حفاظ‌ها) و اثرِ XSS را کم می‌کند؛ مانده: refresh در کوکیِ `HttpOnly`.
## ۲۴. پر کردنِ سقفِ سراسریِ پیامک
- **چیست:** `OTP_SMS_GLOBAL_DAILY_MAX` (۳۰۰ در ۲۴ ساعت) جلوی خرجِ بی‌سقفِ پیامک را می‌گیرد. هر
ناشناسی با درخواستِ کد برای شماره‌های تصادفی آن را پر می‌کند؛ از یک IP بی‌جعل ساعتی ۲۰ درخواست،
یعنی روزی ۴۸۰، پس یک نفر سقف را همیشه پر نگه می‌دارد.
- **چیست:** `OTP_SMS_GLOBAL_DAILY_MAX` (روی سرور ۲٬۰۰۰ در ۲۴ ساعت؛ پیش‌فرضِ کد ۳۰۰) جلوی خرجِ بی‌سقفِ پیامک را می‌گیرد. هر
ناشناسی با درخواستِ کد برای شماره‌های تصادفی آن را پر می‌کند؛ از یک IP بی‌جعل ساعتی ۱۰۰ درخواست؛
با چند IP سقف پر می‌شود.
- **بازتولید شد:** ۳۰۰ درخواست پذیرفته و ۳۰۰ پیامک فرستاده شد؛ بعد درخواستِ کاربرِ واقعی ۵۰۳.
- **خطر:** هیچ ثبت‌نامِ تازه، ورودِ دوباره یا حذفِ حسابی ممکن نیست؛ هزینه‌ی ۳۰۰ پیامک در روز.
- **رفع (تصمیمِ طراحی):** بعد از گذشتن از یک حد، CAPTCHA یا مانعِ مشابه پیش از ارسال؛ شمارشِ سقف
@@ -238,6 +187,9 @@
| شماره‌ی کاربر را فقط ابرکاربر عوض می‌کند؛ حساب‌های کارمند و گروه‌ها فقط با ابرکاربر ساخته/تغییر می‌کنند | `accounts/tests_security_review.py` (`StaffCannotSwapLoginPhoneTests`، `StaffAccountsAdminSuperuserOnlyTests`) |
| لاگِ تحویلِ کمپین اجازه‌ی view می‌خواهد (هر کارمندی نمی‌بیند) | `campaigns/tests_security_review.py` |
| هر رقمِ یونیکد (تمام‌عرض، ریاضی…) در شماره به لاتین برمی‌گردد؛ کدِ غیرِ ASCII، پایه‌ی ناموجود یا عظیم و تاریخِ تولدِ با «²» ۴۰۰ است نه ۵۰۰؛ صافیِ نامِ نمایشی ارقامِ یونیکد و جداکننده‌ی دلخواه را می‌گیرد | `accounts/tests_security_review.py` (`UnicodeInputsAreNormalisedOrRejectedTests`) |
| روی سرور هیچ شماره‌ای با کدِ ورودِ ثابت نیست (`TEST_LOGIN_PHONES` خالی)؛ حساب‌های دمو و داده‌شان پاک شدند؛ اگر کسی آن را دوباره پر کند `/api/health` هشدار می‌دهد | `config/tests.py` (هشدارِ health)؛ روی سرور: `/api/health` بی‌هشدار |
| فقط ArvanCloud به nginxِ دو سایتِ ما می‌رسد؛ درخواستِ مستقیم به IPِ سرور ۴۰۳ (`ops/nginx/zareian-arvan-only.conf`) | از بیرون: پورتِ ۸۰ و ۴۴۳ مستقیم ۴۰۳، از راهِ آروان ۲۰۰ |
| فرانت CSP (فقط منابعِ خودمان، اسکریپتِ درون‌خطی با هش)، `frame-ancestors 'none'`، `X-Frame-Options`، `nosniff`، HSTS و `Permissions-Policy` دارد؛ سقفِ بدنه‌ی nginxِ API ۲۰ مگابایت | `curl -sI`؛ اپ در Chrome با و بی ورود: صفر خطای CSP |
| X-Forwarded-For فقط وقتی پذیرفته می‌شود که peerِ TCP (هدرِ `X-Real-IP`ِ nginx) در `TRUSTED_PROXY_NETWORKS` (رنج‌های ArvanCloud) باشد؛ درخواستِ مستقیم به IP با XFFِ جعلی دیگر سقف‌های IP-محور را دور نمی‌زند | `config/tests_trusted_proxy.py`؛ روی سرور: ۶۵ درخواستِ health با XFFِ چرخان، بعد از ۶۰ تا ۴۲۹ |
| `/api/home?gradeId=` ناموجود کش نمی‌شود (کشِ پُر شمارنده‌ی سقفِ ورودِ ادمین را پاک می‌کرد)؛ `/api/progress/nodes` یک کوئری است و `unit` عظیم ۴۰۰ | `content/tests_security_review.py` (`JunkGradeIdsDoNotFlushCacheTests`، `ProgressNodesInputAndQueriesTests`) |

23
ops/nginx/mathapp Normal file
View File

@@ -0,0 +1,23 @@
# روی ۲۴۹: /etc/nginx/sites-enabled/mathapp (فایلِ معمولی، نه symlink) — API، mathapi.tadrisino2.ir.
server {
listen 80;
listen 443 ssl;
server_name mathapi.tadrisino2.ir;
ssl_certificate /etc/ssl/certs/mathapp.crt;
ssl_certificate_key /etc/ssl/private/mathapp.key;
include /etc/nginx/snippets/zareian-arvan-only.conf;
# بزرگ‌ترین آپلودِ واقعی فایلِ سوال/برنامه‌ی درسی در پنلِ ادمین است (~۹ مگابایت). nginx بدنه را پیش از
# رد شدن روی دیسکِ مشترک می‌نویسد، پس سقفِ ۱۰۰ مگابایتیِ قبلی جای پر کردنِ دیسک می‌داد.
client_max_body_size 20M;
# بقیه‌ی هدرهای امنیتی (nosniff، X-Frame-Options، Referrer-Policy) را خودِ جنگو می‌گذارد.
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
include proxy_params;
proxy_pass http://unix:/run/gunicorn/mathapp.sock;
}
}

31
ops/nginx/mathapp-front Normal file
View File

@@ -0,0 +1,31 @@
# روی ۲۴۹: /etc/nginx/sites-available/mathapp-front (sites-enabled یک symlink به آن است) — math.tadrisino2.ir.
server {
listen 80;
listen 443 ssl;
server_name math.tadrisino2.ir;
ssl_certificate /etc/ssl/certs/mathapp.crt;
ssl_certificate_key /etc/ssl/private/mathapp.key;
root /home/administrator/mathapp_frontend_new/dist;
include /etc/nginx/snippets/zareian-arvan-only.conf;
include /etc/nginx/snippets/zareian-front-headers.conf;
# index.html و config.js اسم ثابت دارند: مرورگر باید هر بار از سرور بپرسد (etag → ۳۰۴)،
# وگرنه بعد از دیپلوی نسخه‌ی قدیمی را نشان می‌دهد.
add_header Cache-Control "no-cache" always;
location / {
index index.html;
try_files $uri $uri/ @fallback;
}
# فایل‌های assets/ اسمشان هش دارد و هیچ‌وقت با همان اسم عوض نمی‌شوند.
location /assets/ {
include /etc/nginx/snippets/zareian-front-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location @fallback {
rewrite ^ /index.html break;
}
}

View File

@@ -0,0 +1,22 @@
# روی ۲۴۹: /etc/nginx/snippets/zareian-arvan-only.conf — داخلِ server {}ِ دو سایتِ ما include می‌شود.
# فقط edgeهای ArvanCloud (و خودِ ماشین) به اپ می‌رسند؛ درخواستِ مستقیم به IPِ سرور ۴۰۳ می‌گیرد، پس سپرِ
# DDoS/WAFِ آروان دور زده نمی‌شود. فهرست از https://www.arvancloud.ir/fa/ips.txt (۲۰۲۶-۱۰-۱۳)؛ همان فهرستِ
# TRUSTED_PROXY_NETWORKS در config/settings.py. اگر آروان رنجِ تازه بگیرد و این‌جا نباشد، کاربرانِ پشتِ آن edge
# ۴۰۳ می‌گیرند (در /var/log/nginx/access.log دیده می‌شود): هر دو فهرست را با هم تازه کنید.
allow 127.0.0.1;
allow ::1;
allow 185.143.232.0/22;
allow 188.229.116.16/30;
allow 94.101.182.0/27;
allow 2.144.3.128/28;
allow 37.32.16.0/27;
allow 37.32.17.0/27;
allow 37.32.18.0/27;
allow 37.32.19.0/27;
allow 185.215.232.0/22;
allow 178.131.120.48/28;
allow 94.101.183.0/28;
allow 78.157.36.112/28;
allow 95.38.61.80/28;
allow 193.24.119.0/29;
deny all;

View File

@@ -0,0 +1,17 @@
# روی ۲۴۹: /etc/nginx/snippets/zareian-front-headers.conf — هدرهای امنیتیِ math.tadrisino2.ir.
# در هر locationی که خودش add_header دارد دوباره include شود: nginx هدرهای سطحِ server را در چنین locationی ارث نمی‌دهد.
#
# CSP فقط منابعِ خودمان را می‌پذیرد:
# - script: فایل‌های خودِ سایت، و تنها اسکریپتِ درون‌خطیِ index.html (کلاسِ in-app برای اسپلش) با هشش. اگر آن
# اسکریپت در فرانت عوض شود، هش را از نو بسازید (python: base64(sha256(متنِ بینِ <script> و </script>)))؛
# وگرنه فقط همان اسکریپت اجرا نمی‌شود و کنسول «Refused to execute inline script» می‌گوید.
# - connect: API. با عوض شدنِ دامنه‌ی API (config.js) این‌جا هم عوض شود (docs/operations.md «عوض کردنِ دامنه»).
# - worker blob: کتابخانه‌ی confetti یک Worker از blob می‌سازد.
# - style 'unsafe-inline': <style>ِ index.html و pay.html و استایلِ درون‌خطیِ React.
# HSTS بی includeSubDomains: زیردامنه‌های دیگرِ tadrisino2.ir مالِ تیم‌های دیگرند.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-utBc36Q6M7NacvbHQCaMGCgm3osZ627xGQGm/tnSEXM='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://mathapi.tadrisino2.ir; worker-src 'self' blob:; media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Strict-Transport-Security "max-age=31536000" always;