From 8effe5b60b03a28a28cb5493d82861105ecce1cb Mon Sep 17 00:00:00 2001 From: Zareian Date: Mon, 14 Sep 2026 01:20:02 +0330 Subject: [PATCH] secure the admin session and make traffic visible from outside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit دو شکاف که وقتی آدم‌های دیگر به پروژه وصل می‌شوند مهم‌تر می‌شوند. کوکیِ نشستِ پنلِ ادمین و توکنِ CSRF بدونِ پرچمِ secure رد و بدل می‌شدند. احرازِ هویتِ خودِ API کوکی نیست و این‌ها فقط برای ادمین‌اند — یعنی دقیقاً همان‌جایی که اعتبارنامه‌ی واقعی وارد می‌شود. حالا خارج از DEBUG امن‌اند، و SECURE_PROXY_SSL_HEADER هم اضافه شد چون TLS روی nginx تمام می‌شود و بدونش جنگو درخواست را HTTP می‌بیند و کوکیِ امن اصلاً ست نمی‌شود. SECURE_SSL_REDIRECT و HSTS عمداً روشن نشدند: اولی پشتِ پراکسیِ بدتنظیم حلقه‌ی ریدایرکت می‌سازد و کلِ سایت را می‌خواباند، دومی در مرورگرِ کاربر کش می‌شود و عملاً برگشت‌ناپذیر است. هر دو تصمیمِ زیرساختی‌اند نه کد. /api/health حالا ترافیک را هم گزارش می‌دهد: تعدادِ کاربر، فعال‌های ۲۴ ساعتِ اخیر، و زمانِ آخرین فعالیت. تا امروز هیچ راهی نبود از بیرون بفهمیم فرانت واقعاً به سرور رسیده یا نه — باید منتظر می‌ماندیم کسی بگوید. فقط شمارش و زمان؛ تست جداگانه بررسی می‌کند هیچ شماره یا نامی بیرون نرود. Co-Authored-By: Claude Opus 5 --- config/health.py | 29 ++++++++++++++++++++++ config/settings.py | 16 ++++++++++++ config/tests.py | 47 ++++++++++++++++++++++++++++++++++++ content/tests_question_io.py | 41 +++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+) diff --git a/config/health.py b/config/health.py index 4fab0cd..893a15c 100644 --- a/config/health.py +++ b/config/health.py @@ -12,6 +12,8 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟» عمداً هیچ سکرتی برنمی‌گرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا. """ +from datetime import timedelta + from django.conf import settings from django.core.cache import cache from django.db import connection @@ -79,6 +81,32 @@ def _check_content() -> dict: return {'ok': False, 'error': type(exc).__name__} +def _traffic() -> dict: + """ + آیا کسی واقعاً به این سرور وصل می‌شود؟ + + تا امروز راهی نبود که از بیرون بفهمیم فرانت به سرور رسیده یا نه — باید + منتظر می‌ماندیم کسی بگوید. این سه عدد همان سیگنال‌اند. فقط شمارش و زمان، + بدونِ هیچ اطلاعاتِ شخصی. + """ + try: + from django.utils import timezone + + from accounts.models import User + + since = timezone.now() - timedelta(hours=24) + last = User.objects.order_by('-last_activity_at').values_list( + 'last_activity_at', flat=True, + ).first() + return { + 'users': User.objects.count(), + 'activeLast24h': User.objects.filter(last_activity_at__gte=since).count(), + 'lastActivityAt': last.isoformat() if last else None, + } + except Exception as exc: # noqa: BLE001 + return {'error': type(exc).__name__} + + def _check_integrations() -> dict: """فقط «تنظیم شده یا نه» — هیچ کلیدی برنمی‌گردد.""" return { @@ -142,6 +170,7 @@ class HealthView(APIView): 'warnings': warnings, 'checks': checks, 'integrations': _check_integrations(), + 'traffic': _traffic(), 'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])), } return Response(payload, status=200 if critical_ok else 503) diff --git a/config/settings.py b/config/settings.py index 9311440..ea40591 100644 --- a/config/settings.py +++ b/config/settings.py @@ -104,6 +104,22 @@ CSRF_TRUSTED_ORIGINS = _with_defaults( ['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir'], ) +# --- امنیتِ پشتِ پراکسی --------------------------------------------------- +# TLS روی nginx/ArvanCloud تمام می‌شود و جنگو درخواست را HTTP می‌بیند؛ بدونِ +# این هدر، کوکی‌های امن هرگز ست نمی‌شوند و ورود به پنلِ ادمین می‌شکند. +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +# فقط در پروداکشن: کوکیِ نشستِ ادمین و توکنِ CSRF نباید روی HTTP رد و بدل شوند. +# (احرازِ هویتِ API ما کوکی نیست — این‌ها برای پنلِ ادمین‌اند، یعنی همان‌جایی +# که اعتبارنامه‌ی واقعی وارد می‌شود.) +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SECURE = not DEBUG +SESSION_COOKIE_HTTPONLY = True + +# SECURE_SSL_REDIRECT و HSTS عمداً روشن نشده‌اند: اولی پشتِ پراکسیِ بدتنظیم +# حلقه‌ی ریدایرکت می‌سازد و کلِ سایت را می‌خواباند، دومی هم در مرورگرِ کاربر +# کش می‌شود و عملاً برگشت‌ناپذیر است. هر دو تصمیمِ زیرساختی‌اند، نه کد. + # --- CORS ------------------------------------------------------------------- # فرانت‌اند (Vite/React) از یک origin دیگر صدا می‌زند، پس بدونِ این هدرها مرورگر # هر درخواستِ /api/ را قبل از رسیدن به Django رد می‌کند. diff --git a/config/tests.py b/config/tests.py index 7d4e5f1..f3c6f60 100644 --- a/config/tests.py +++ b/config/tests.py @@ -356,3 +356,50 @@ class ServerEnvDefaultsTests(SimpleTestCase): self.assertTrue(settings.PUBLIC_BASE_URL.startswith('https://')) self.assertTrue(settings.ZARINPAL_CALLBACK_URL.startswith('https://')) self.assertIn('/api/payment/zarinpal/callback', settings.ZARINPAL_CALLBACK_URL) + + +class SecureCookieTests(SimpleTestCase): + """ + کوکیِ نشستِ ادمین جایی‌ست که اعتبارنامه‌ی واقعی وارد می‌شود؛ روی HTTP + نباید رد و بدل شود. + + مثلِ بقیه‌ی تست‌های این فایل ماژول را دوباره بار می‌کنیم: این مقدارها در + زمانِ import محاسبه می‌شوند و اجرای تست خودش DEBUG را خاموش می‌کند، پس + خواندنِ settings در لحظه چیزِ گمراه‌کننده‌ای می‌دهد. + """ + + allow_database_queries = True + + def _reload(self, env_overrides, env_removed=()): + from config import settings as settings_module + + clean_env = {k: v for k, v in os.environ.items() if k not in env_removed} + clean_env.update(env_overrides) + with patch.dict(os.environ, clean_env, clear=True): + importlib.reload(settings_module) + return settings_module + + def tearDown(self): + from config import settings as settings_module + importlib.reload(settings_module) + + def test_cookies_are_secure_in_production(self): + module = self._reload( + {'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'x' * 60, + 'CACHE_URL': 'redis://localhost:6379/0'}, + ) + self.assertFalse(module.DEBUG) + self.assertTrue(module.SESSION_COOKIE_SECURE) + self.assertTrue(module.CSRF_COOKIE_SECURE) + self.assertTrue(module.SESSION_COOKIE_HTTPONLY) + + def test_cookies_stay_usable_over_plain_http_in_development(self): + """با DEBUG روشن، کوکیِ امن یعنی ورود به ادمینِ محلی کار نمی‌کند.""" + module = self._reload({'DJANGO_DEBUG': '1'}) + self.assertTrue(module.DEBUG) + self.assertFalse(module.SESSION_COOKIE_SECURE) + + def test_django_trusts_the_proxy_for_https(self): + """بدونِ این، کوکیِ امن پشتِ nginx هرگز ست نمی‌شود و ورود می‌شکند.""" + self.assertEqual(settings.SECURE_PROXY_SSL_HEADER, + ('HTTP_X_FORWARDED_PROTO', 'https')) diff --git a/content/tests_question_io.py b/content/tests_question_io.py index f565175..72a6ef1 100644 --- a/content/tests_question_io.py +++ b/content/tests_question_io.py @@ -229,3 +229,44 @@ class ReplaceCountTests(TestCase): result = import_questions(parse(payload), replace=True) self.assertEqual(result['removed'], 1, 'باید ۱ باشد نه ۵') + + +class HealthTrafficTests(TestCase): + """ + سیگنالِ «کسی وصل شده یا نه» — تنها راهی که از بیرون می‌شود فهمید فرانت + واقعاً به سرور رسیده است. + """ + + def test_reports_counts_and_last_activity(self): + from django.utils import timezone + + from accounts.models import User + + User.objects.create(phone='09120000009', last_activity_at=timezone.now()) + traffic = self.client.get('/api/health').json()['traffic'] + + self.assertEqual(traffic['users'], 1) + self.assertEqual(traffic['activeLast24h'], 1) + self.assertIsNotNone(traffic['lastActivityAt']) + + def test_an_idle_user_is_not_counted_as_active(self): + from datetime import timedelta + + from django.utils import timezone + + from accounts.models import User + + User.objects.create(phone='09120000010', + last_activity_at=timezone.now() - timedelta(days=3)) + traffic = self.client.get('/api/health').json()['traffic'] + self.assertEqual(traffic['users'], 1) + self.assertEqual(traffic['activeLast24h'], 0) + + def test_it_leaks_no_personal_data(self): + from accounts.models import User + + User.objects.create(phone='09121234567', name='علی') + raw = self.client.get('/api/health').content.decode() + self.assertNotIn('09121234567', raw) + self.assertNotIn('علی', raw) +