Close /api/health details and API docs to the public

Health: public gets only ok/error; details need X-Internal-Key. Docs/schema: admin session only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:05:19 +03:30
parent d6529956e1
commit 22c3196343
9 changed files with 74 additions and 53 deletions

View File

@@ -71,8 +71,7 @@ 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 `/api/health`, the API
schema, the leaderboard and `/admin/` are all public. (The token signing key is no longer the
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
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).

View File

@@ -190,6 +190,9 @@ def _warnings() -> list[str]:
return out
from payment.permissions import HasInternalServiceKey # noqa: E402
class HealthView(APIView):
"""
GET /api/health — وضعیتِ این نسخه‌ی مستقرشده.
@@ -213,6 +216,13 @@ class HealthView(APIView):
}
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
status = 200 if critical_ok else 503
# عموم فقط ok/error می‌بینند؛ جزئیات (هشدارها، ترافیک، سرویس‌های وصل‌شده، IP) پشتِ هدرِ
# X-Internal-Key. کد ۲۰۰/۵۰۳ عمومی می‌ماند تا پایشِ بیرونی کار کند. اگر INTERNAL_SERVICE_KEY
# در .env نباشد، هیچ‌کس جزئیات را نمی‌بیند (نه اینکه همه ببینند).
if not HasInternalServiceKey().has_permission(request, self):
return Response({'status': 'ok' if critical_ok else 'error'}, status=status)
warnings = _warnings()
payload = {
'status': 'ok' if critical_ok else 'error',
@@ -228,4 +238,4 @@ class HealthView(APIView):
# را به خودش برمی‌گرداند، پس چیزی لو نمی‌دهد.
'clientIp': ScopedRateThrottle().get_ident(request),
}
return Response(payload, status=200 if critical_ok else 503)
return Response(payload, status=status)

View File

@@ -636,6 +636,11 @@ SPECTACULAR_SETTINGS = {
{'name': 'System', 'description': 'سلامتِ سرویس'},
],
'SERVE_INCLUDE_SCHEMA': False,
# مستندات فقط برای ادمینِ واردشده به /admin/. IsAdminUser به‌تنهایی کافی نیست: احرازِ پیش‌فرضِ DRF
# فقط Bearer است و ادمینِ پنل ۴۰۱ می‌گرفت؛ SessionAuthentication کوکیِ پنل را می‌شناسد.
# فایلِ schema برای فرانت: `python manage.py spectacular --file schema.yaml` (بی‌نیاز از HTTP).
'SERVE_PERMISSIONS': ['rest_framework.permissions.IsAdminUser'],
'SERVE_AUTHENTICATION': ['rest_framework.authentication.SessionAuthentication'],
'COMPONENT_SPLIT_REQUEST': True,
# دو فیلدِ «kind» با دو مجموعه‌ی مقدار: نوعِ درس و نوعِ پلن (بسته/اشتراک، از ۲۰۲۶-۱۰-۰۲).
# بدونِ نامِ صریح، drf-spectacular یکی را «KindEd8Enum» می‌نامید و هشدار می‌داد.

View File

@@ -467,6 +467,15 @@ class ApiDocsRtlShellTests(TestCase):
دیگری نمی‌گیردش.
"""
def setUp(self):
from django.contrib.auth.models import User as AuthUser
self.client.force_login(AuthUser.objects.create_superuser('docs-admin', 'd@x.ir', 'pw'))
def test_docs_are_closed_to_anonymous_visitors(self):
from rest_framework.test import APIClient
for path in ('/api/docs/', '/api/redoc/', '/api/schema/'):
self.assertIn(APIClient().get(path).status_code, (401, 403), path)
def test_swagger_ui_carries_the_rtl_shell(self):
html = self.client.get('/api/docs/').content.decode()
self.assertIn('direction: rtl', html)
@@ -490,3 +499,20 @@ class InsecureSecretKeyWarningTests(TestCase):
self.assertIn('django-insecure', ' '.join(health._warnings()))
with override_settings(DEBUG=False, SECRET_KEY='a-real-random-key'):
self.assertNotIn('django-insecure', ' '.join(health._warnings()))
class HealthIsMinimalForThePublicTests(TestCase):
"""/api/health بی‌کلید فقط ok/error می‌دهد؛ با کلیدِ درست جزئیات."""
@override_settings(INTERNAL_SERVICE_KEY='k')
def test_public_sees_only_status(self):
body = self.client.get('/api/health').json()
self.assertEqual(set(body), {'status'})
wrong = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='nope').json()
self.assertEqual(set(wrong), {'status'})
full = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
self.assertIn('traffic', full)
@override_settings(INTERNAL_SERVICE_KEY='')
def test_without_a_configured_key_nobody_gets_details(self):
self.assertEqual(set(self.client.get('/api/health', HTTP_X_INTERNAL_KEY='').json()), {'status'})

View File

@@ -36,6 +36,7 @@ class _Quiet:
@override_settings(CACHES=_caches('redis://127.0.0.1:1/0'))
@override_settings(INTERNAL_SERVICE_KEY='k')
class RedisDownTests(_Quiet, TestCase):
"""پورتِ ۱ بسته است: هر اتصال فوراً رد می‌شود."""
@@ -86,7 +87,7 @@ class RedisDownTests(_Quiet, TestCase):
self.assertNotEqual(response.status_code, 500)
def test_health_says_so(self):
response = APIClient().get('/api/health')
response = APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k')
self.assertEqual(response.status_code, 503)
self.assertIs(response.json()['checks']['cache']['ok'], False)
@@ -107,6 +108,7 @@ def _free_port():
@unittest.skipUnless(shutil.which('redis-server'), 'redis-server نصب نیست')
@override_settings(INTERNAL_SERVICE_KEY='k')
class RealRedisTests(_Quiet, TestCase):
def setUp(self):
super().setUp()
@@ -155,7 +157,7 @@ class RealRedisTests(_Quiet, TestCase):
def test_throttle_is_shared_and_health_is_ok(self):
with override_settings(CACHES=_caches(f'redis://127.0.0.1:{self.port}/1')):
response = APIClient().get('/api/health')
response = APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k')
self.assertIs(response.json()['checks']['cache']['ok'], True)
def test_redis_dying_mid_flight_and_coming_back_without_restarting_the_app(self):
@@ -168,9 +170,9 @@ class RealRedisTests(_Quiet, TestCase):
with self.assertLogs('config.cache', 'ERROR'):
self.assertEqual(c.get('k', 'miss'), 'miss')
self.assertEqual(APIClient().get('/api/grades').status_code, 200)
self.assertEqual(APIClient().get('/api/health').status_code, 503)
self.assertEqual(APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 503)
self.start() # برمی‌گردد، بدونِ ری‌استارتِ برنامه
c.set('k2', 'after', 60)
self.assertEqual(c.get('k2'), 'after')
self.assertEqual(APIClient().get('/api/health').status_code, 200)
self.assertEqual(APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 200)

View File

@@ -6,7 +6,7 @@
"""
import json
from django.test import TestCase
from django.test import override_settings, TestCase
from content.models import Grade, Lesson, Node, Question, Unit
from content.question_io import ImportError_, export_questions, import_questions, parse
@@ -77,9 +77,10 @@ class QuestionImportTests(TestCase):
self.assertEqual(Question.objects.get().choices.filter(is_correct=True).get().value, '۵')
@override_settings(INTERNAL_SERVICE_KEY='k')
class HealthEndpointTests(TestCase):
def test_reports_ok_and_counts_content(self):
response = self.client.get('/api/health')
response = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k')
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body['status'], 'ok')
@@ -88,12 +89,12 @@ class HealthEndpointTests(TestCase):
self.assertIn('questions', body['checks']['content'])
def test_never_leaks_a_secret(self):
raw = self.client.get('/api/health').content.decode()
raw = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').content.decode()
for needle in ('sk-', 'SECRET', 'API_KEY', 'password'):
self.assertNotIn(needle, raw)
def test_needs_no_authentication(self):
self.assertEqual(self.client.get('/api/health').status_code, 200)
self.assertEqual(self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 200)
class CurriculumImportTests(TestCase):
@@ -169,19 +170,21 @@ class CurriculumImportTests(TestCase):
self.assertEqual(Lesson.objects.get().title, 'ضرب توان‌ها')
@override_settings(INTERNAL_SERVICE_KEY='k')
class HealthWarningTests(TestCase):
def test_debug_mode_is_reported_as_a_warning(self):
with self.settings(DEBUG=True, DEV_MASTER_OTP='0000'):
body = self.client.get('/api/health').json()
body = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
joined = ' '.join(body['warnings'])
self.assertIn('DEBUG', joined)
self.assertIn('0000', joined)
def test_allowed_origins_include_the_deployed_frontend(self):
body = self.client.get('/api/health').json()
body = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
self.assertIn('https://math.tadrisino2.ir', body['allowedOrigins'])
@override_settings(INTERNAL_SERVICE_KEY='k')
class MigrationCheckTests(TestCase):
"""
این بررسی مهم‌ترین چیزی‌ست که /api/health می‌گوید، و تنها وقتی اهمیت دارد
@@ -231,6 +234,7 @@ class ReplaceCountTests(TestCase):
self.assertEqual(result['removed'], 1, 'باید ۱ باشد نه ۵')
@override_settings(INTERNAL_SERVICE_KEY='k')
class HealthTrafficTests(TestCase):
"""
سیگنالِ «کسی وصل شده یا نه» — تنها راهی که از بیرون می‌شود فهمید فرانت
@@ -243,7 +247,7 @@ class HealthTrafficTests(TestCase):
from accounts.models import User
User.objects.create(phone='09120000009', last_activity_at=timezone.now())
traffic = self.client.get('/api/health').json()['traffic']
traffic = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()['traffic']
self.assertEqual(traffic['users'], 1)
self.assertEqual(traffic['activeLast24h'], 1)
@@ -258,7 +262,7 @@ class HealthTrafficTests(TestCase):
User.objects.create(phone='09120000010',
last_activity_at=timezone.now() - timedelta(days=3))
traffic = self.client.get('/api/health').json()['traffic']
traffic = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()['traffic']
self.assertEqual(traffic['users'], 1)
self.assertEqual(traffic['activeLast24h'], 0)
@@ -266,7 +270,7 @@ class HealthTrafficTests(TestCase):
from accounts.models import User
User.objects.create(phone='09121234567', name='علی')
raw = self.client.get('/api/health').content.decode()
raw = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').content.decode()
self.assertNotIn('09121234567', raw)
self.assertNotIn('علی', raw)

View File

@@ -47,9 +47,13 @@ poll کردنِ `/api/health`.
## بررسیِ سلامت
```bash
curl https://mathapi.tadrisino2.ir/api/health
curl https://mathapi.tadrisino2.ir/api/health # عمومی: فقط {"status": "ok"|"error"}
curl -H "X-Internal-Key: <INTERNAL_SERVICE_KEY>" https://mathapi.tadrisino2.ir/api/health # جزئیات
```
کلید در `.env` سرور است (`INTERNAL_SERVICE_KEY`)؛ جایی ننویسید و در چت نفرستید. مستنداتِ API (`/api/docs/`، `/api/redoc/`، `/api/schema/`) فقط برای ادمینِ واردشده به
`/admin/` باز است؛ برای فرانت: `python manage.py spectacular --file schema.yaml --format openapi`.
بدونِ احرازِ هویت و بدونِ SSH می‌گوید: دیتابیس سالم است یا نه، مهاجرتِ
اعمال‌نشده مانده یا نه، کش کار می‌کند یا نه، چند پایه/درس/سوال روی آن سرور
هست، کدام سرویسِ بیرونی تنظیم شده (فقط بله/خیر)، و چه هشدارهای امنیتی‌ای
@@ -111,7 +115,7 @@ curl https://mathapi.tadrisino2.ir/api/health
| `DJANGO_SECRET_KEY` | یک کلیدِ تصادفیِ قوی ساخته و در `.django_secret_key` (مجوز `0600`) ذخیره می‌شود |
| `PUBLIC_BASE_URL` · `FRONTEND_URL` | دامنه‌ی فعلی (`mathapi.tadrisino2.ir` / `math.tadrisino2.ir`). بقیه‌ی تنظیم‌های دامنه از همین دو ساخته می‌شوند؛ بخشِ «عوض کردنِ دامنه» |
| `DJANGO_ALLOWED_HOSTS` · `CSRF_TRUSTED_ORIGINS` · `CORS_ALLOWED_ORIGINS` | از دو آدرسِ بالا ساخته می‌شوند؛ مقدارِ env فقط دامنه‌ی *اضافه* است |
| `CACHE_URL` | سرویس بالا می‌آید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار می‌دهد. **روی سرور خالی است عمداً**: Redisِ آن ماشین مالِ تیم‌های دیگر است و به آن وصل نمی‌شویم؛ بعد از ساختنِ Redisِ اختصاصیِ خودمان این را پر کنید. با آن، قطعیِ Redis API را ۵۰۰ نمی‌کند ولی تا برگشتنش سقف‌های مبتنی بر کش اعمال نمی‌شوند و health ۵۰۳ می‌دهد (`config/cache.py`) |
| `CACHE_URL` | سرویس بالا می‌آید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار می‌دهد. روی سرور به Redisِ اختصاصیِ خودمان (`zareian-redis`، پورت ۶۳۹۰) اشاره می‌کند، نه Redisِ مشترکِ ماشین. با آن، قطعیِ Redis API را ۵۰۰ نمی‌کند ولی تا برگشتنش سقف‌های مبتنی بر کش اعمال نمی‌شوند و health ۵۰۳ می‌دهد (`config/cache.py`) |
| `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `503` می‌دهد، نه موفقیتِ ساختگی |
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed می‌ماند (`503`) |
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشه‌ی کاری می‌خواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ هم‌زمانِ دستیار کلِ API را قفل می‌کرد |

View File

@@ -23,8 +23,6 @@
|---|---|---|---|
| ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور |
| ۳ | داده‌ی دمو روی دیتابیسِ پروداکشن | بالا | دیتابیسِ سرور |
| ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان می‌دهد | متوسط | کد |
| ۵ | Swagger، ReDoc و schemaی کاملِ API عمومی‌اند | متوسط | کد |
| ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد |
| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور |
| ۹ | کشِ مشترک بینِ workerها نیست (Redisِ اختصاصی)؛ سقف‌های مبتنی بر کش ≈ ۳ برابرند | متوسط | تصمیم + ساختِ Redis |
@@ -63,36 +61,6 @@
`purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ واقعیِ viewها ساخته شده‌اند).
- **چطور بسنجید:** `/api/health` → `traffic.users` فقط کاربرانِ واقعی را بشمارد.
## ۴. `/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://mathapi.tadrisino2.ir/api/health` بدونِ هدر نباید
`warnings` و `traffic` داشته باشد.
## ۵. مستنداتِ API عمومی
- **چیست:** `/api/docs/` (Swagger)، `/api/redoc/` و `/api/schema/` بدونِ ورود باز‌اند
(تأیید: HTTP 200). schema همه‌ی endpointها، پارامترها، کدهای خطا و هدرهای داخلی
(`X-Internal-Key`) را فهرست می‌کند.
- **خطر:** شناساییِ کاملِ سطحِ حمله بدونِ زحمت.
- **چرا باز مانده:** قراردادِ مشترک با فرانت همین‌جاست؛ فرانت‌کار و ارائه به آن نیاز دارند.
- **رفع:** مسیر را در 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/` باید ۴۰۱/۴۰۳ بدهد.
## ۷. پنلِ ادمین روی دامنه‌ی عمومی
- **چیست:** `/admin/` روی `mathapi.tadrisino2.ir`، با ورودِ نام‌کاربری/رمزِ جنگو. سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)،
@@ -259,6 +227,8 @@
| پلنِ هر خرید روی سفارش؛ خرید اشتراکِ فعال را دست نمی‌زند؛ یک کدِ تخفیف پیش از درگاه؛ امتیاز فقط از جوابِ ثبت‌شده | `payment/tests_audit_fixes.py`، `accounts/tests_audit_fixes.py` |
| لینکِ پرداخت (`/api/payment/orders/{id}/pay`) فقط با امضای کوتاه‌عمرِ همراهِ `paymentUrl` باز می‌شود | `payment/tests.py` (`PaymentRedirectViewTests`) |
| توکنِ تمدید فقط به‌صورتِ هشِ SHA-256 ذخیره می‌شود | `accounts/tests.py` (`RefreshTokenStoredHashedTests`) |
| `/api/health` بی‌کلید فقط `ok`/`error` می‌دهد؛ جزئیات با هدرِ `X-Internal-Key` (کلید در `.env` سرور). بی‌کلیدِ تنظیم‌شده هیچ‌کس جزئیات نمی‌بیند | `config/tests.py` (`HealthIsMinimalForThePublicTests`) |
| Swagger، ReDoc و schema فقط برای ادمینِ واردشده به `/admin/` | `config/tests.py` (`ApiDocsRtlShellTests.test_docs_are_closed_to_anonymous_visitors`) |
| جدولِ رتبه‌بندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) |
| شماره‌ی موبایل نرمال می‌شود؛ cooldown، سقفِ روزانه‌ی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده می‌شوند | `accounts/tests_otp_limits.py` |
| OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکن‌های قبلی را باطل می‌کند | `accounts/tests_audit_fixes.py` |

View File

@@ -225,6 +225,7 @@ class ConversationMemoryTests(TestCase):
@override_settings(**RAG_ON)
@override_settings(INTERNAL_SERVICE_KEY='k')
class ProviderStateTests(TestCase):
"""اعتبارِ تمام‌شده باید در /api/health دیده شود، نه فقط در لاگ."""
@@ -247,17 +248,17 @@ class ProviderStateTests(TestCase):
quota = RuntimeError("Error code: 403 - insufficient user quota, remaining quota: $-0.001")
self.assertEqual(self._ask({'side_effect': quota}).status_code, 503)
health = self.client.get('/api/health').json()
health = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
self.assertEqual(health['integrations']['aiProvider']['state'], 'quota_exhausted')
self.assertTrue(any('GapGPT' in w for w in health['warnings']))
self.assertEqual(self._ask({'return_value': ('جواب', [], {})}).status_code, 200)
health = self.client.get('/api/health').json()
health = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')
def test_an_ordinary_network_error_does_not_claim_the_credit_is_gone(self):
self.assertEqual(self._ask({'side_effect': ConnectionError('timeout')}).status_code, 503)
health = self.client.get('/api/health').json()
health = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')