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.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)

View File

@@ -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 رد می‌کند.

View File

@@ -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'))

View File

@@ -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)