From ba4ecff29e7e6def441dd425c8b66884b18da915 Mon Sep 17 00:00:00 2001 From: Zareian Date: Tue, 13 Oct 2026 19:17:11 +0330 Subject: [PATCH] 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 --- CLAUDE.md | 45 +++++++++++---- config/health.py | 4 +- config/tests_no_fixed_logins.py | 20 +++++++ docs/operations.md | 83 +++++++++++++--------------- docs/security-before-production.md | 68 ++++------------------- ops/nginx/mathapp | 23 ++++++++ ops/nginx/mathapp-front | 31 +++++++++++ ops/nginx/zareian-arvan-only.conf | 22 ++++++++ ops/nginx/zareian-front-headers.conf | 17 ++++++ 9 files changed, 196 insertions(+), 117 deletions(-) create mode 100644 config/tests_no_fixed_logins.py create mode 100644 ops/nginx/mathapp create mode 100644 ops/nginx/mathapp-front create mode 100644 ops/nginx/zareian-arvan-only.conf create mode 100644 ops/nginx/zareian-front-headers.conf diff --git a/CLAUDE.md b/CLAUDE.md index 6754623..bfc2c81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 خرج می‌کند؛ سقفِ ۱۵ در روز برای هر کاربر آن را محدود نگه می‌دارد. diff --git a/config/health.py b/config/health.py index b98d24e..6aa9fb0 100644 --- a/config/health.py +++ b/config/health.py @@ -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): این کلید در گیت است و هر diff --git a/config/tests_no_fixed_logins.py b/config/tests_no_fixed_logins.py new file mode 100644 index 0000000..fda9bcb --- /dev/null +++ b/config/tests_no_fixed_logins.py @@ -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())) diff --git a/docs/operations.md b/docs/operations.md index f757ec7..f472921 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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` است. فهرست را + از در snippet و در `config/settings.py` با هم تازه کنید. +- اسکریپتِ درون‌خطیِ `index.html`ِ فرانت عوض شده: CSP آن را می‌بندد (کنسول: «Refused to execute inline script»). هشِ تازه را در + snippet بگذارید (روش در خودِ فایل). +- سنجیده (۲۰۲۶-۱۰-۱۳): اپ در Chrome، بی‌ورود و با ورود، همه‌ی تب‌ها و آزمونِ یک جزیره، بی هیچ خطای CSP. **نسنجیده:** اپِ اندروید + (WebView همان CSP را می‌گیرد) و صفحه‌ی `pay.html` تا درگاه. + ## لاگ‌ها هر درخواستِ `/api/` به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌شود diff --git a/docs/security-before-production.md b/docs/security-before-production.md index 41b431d..40fa162 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -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`) | diff --git a/ops/nginx/mathapp b/ops/nginx/mathapp new file mode 100644 index 0000000..efd2955 --- /dev/null +++ b/ops/nginx/mathapp @@ -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; + } +} diff --git a/ops/nginx/mathapp-front b/ops/nginx/mathapp-front new file mode 100644 index 0000000..c6c830d --- /dev/null +++ b/ops/nginx/mathapp-front @@ -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; + } +} diff --git a/ops/nginx/zareian-arvan-only.conf b/ops/nginx/zareian-arvan-only.conf new file mode 100644 index 0000000..05eec76 --- /dev/null +++ b/ops/nginx/zareian-arvan-only.conf @@ -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; diff --git a/ops/nginx/zareian-front-headers.conf b/ops/nginx/zareian-front-headers.conf new file mode 100644 index 0000000..b7aaff3 --- /dev/null +++ b/ops/nginx/zareian-front-headers.conf @@ -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(متنِ بینِ )))؛ +# وگرنه فقط همان اسکریپت اجرا نمی‌شود و کنسول «Refused to execute inline script» می‌گوید. +# - connect: API. با عوض شدنِ دامنه‌ی API (config.js) این‌جا هم عوض شود (docs/operations.md «عوض کردنِ دامنه»). +# - worker blob: کتابخانه‌ی confetti یک Worker از blob می‌سازد. +# - style 'unsafe-inline':