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:
@@ -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)
|
||||
|
||||
@@ -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 رد میکند.
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user