secure the admin session and make traffic visible from outside

دو شکاف که وقتی آدم‌های دیگر به پروژه وصل می‌شوند مهم‌تر می‌شوند.

کوکیِ نشستِ پنلِ ادمین و توکنِ CSRF بدونِ پرچمِ secure رد و بدل می‌شدند.
احرازِ هویتِ خودِ API کوکی نیست و این‌ها فقط برای ادمین‌اند — یعنی دقیقاً
همان‌جایی که اعتبارنامه‌ی واقعی وارد می‌شود. حالا خارج از DEBUG امن‌اند، و
SECURE_PROXY_SSL_HEADER هم اضافه شد چون TLS روی nginx تمام می‌شود و بدونش
جنگو درخواست را HTTP می‌بیند و کوکیِ امن اصلاً ست نمی‌شود.

SECURE_SSL_REDIRECT و HSTS عمداً روشن نشدند: اولی پشتِ پراکسیِ بدتنظیم
حلقه‌ی ریدایرکت می‌سازد و کلِ سایت را می‌خواباند، دومی در مرورگرِ کاربر کش
می‌شود و عملاً برگشت‌ناپذیر است. هر دو تصمیمِ زیرساختی‌اند نه کد.

/api/health حالا ترافیک را هم گزارش می‌دهد: تعدادِ کاربر، فعال‌های ۲۴ ساعتِ
اخیر، و زمانِ آخرین فعالیت. تا امروز هیچ راهی نبود از بیرون بفهمیم فرانت
واقعاً به سرور رسیده یا نه — باید منتظر می‌ماندیم کسی بگوید. فقط شمارش و
زمان؛ تست جداگانه بررسی می‌کند هیچ شماره یا نامی بیرون نرود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 01:20:02 +03:30
parent 39a9f435ac
commit 8effe5b60b
4 changed files with 133 additions and 0 deletions

View File

@@ -12,6 +12,8 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟»
عمداً هیچ سکرتی برنمی‌گرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا. عمداً هیچ سکرتی برنمی‌گرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا.
""" """
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.db import connection from django.db import connection
@@ -79,6 +81,32 @@ def _check_content() -> dict:
return {'ok': False, 'error': type(exc).__name__} 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: def _check_integrations() -> dict:
"""فقط «تنظیم شده یا نه» — هیچ کلیدی برنمی‌گردد.""" """فقط «تنظیم شده یا نه» — هیچ کلیدی برنمی‌گردد."""
return { return {
@@ -142,6 +170,7 @@ class HealthView(APIView):
'warnings': warnings, 'warnings': warnings,
'checks': checks, 'checks': checks,
'integrations': _check_integrations(), 'integrations': _check_integrations(),
'traffic': _traffic(),
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])), 'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
} }
return Response(payload, status=200 if critical_ok else 503) return Response(payload, status=200 if critical_ok else 503)

View File

@@ -104,6 +104,22 @@ CSRF_TRUSTED_ORIGINS = _with_defaults(
['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir'], ['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 ------------------------------------------------------------------- # --- CORS -------------------------------------------------------------------
# فرانت‌اند (Vite/React) از یک origin دیگر صدا می‌زند، پس بدونِ این هدرها مرورگر # فرانت‌اند (Vite/React) از یک origin دیگر صدا می‌زند، پس بدونِ این هدرها مرورگر
# هر درخواستِ /api/ را قبل از رسیدن به Django رد می‌کند. # هر درخواستِ /api/ را قبل از رسیدن به Django رد می‌کند.

View File

@@ -356,3 +356,50 @@ class ServerEnvDefaultsTests(SimpleTestCase):
self.assertTrue(settings.PUBLIC_BASE_URL.startswith('https://')) self.assertTrue(settings.PUBLIC_BASE_URL.startswith('https://'))
self.assertTrue(settings.ZARINPAL_CALLBACK_URL.startswith('https://')) self.assertTrue(settings.ZARINPAL_CALLBACK_URL.startswith('https://'))
self.assertIn('/api/payment/zarinpal/callback', settings.ZARINPAL_CALLBACK_URL) 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'))

View File

@@ -229,3 +229,44 @@ class ReplaceCountTests(TestCase):
result = import_questions(parse(payload), replace=True) result = import_questions(parse(payload), replace=True)
self.assertEqual(result['removed'], 1, 'باید ۱ باشد نه ۵') 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)