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

@@ -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 در هر خط لاگ می‌شود