boot with sane defaults instead of refusing to start
سه بار پشتِ سر هم همین الگو تکرار شد: یک متغیرِ محیطی ست نبود، جنگو موقعِ بالا آمدن مرد، و از بیرون فقط «502 Bad Gateway» دیده شد. کسی که سرور را بالا میآورد هیچ سرنخی نداشت، و تشخیص هر بار یک رفتوبرگشتِ یکروزه بود. ALLOWED_HOSTS و CSRF_TRUSTED_ORIGINS حالا دامنههای شناختهشدهی خودمان را پیشفرض دارند. CSRF مخصوصاً مهم است: بدونش ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS «CSRF verification failed» میدهد و علتش از پیام معلوم نیست. SECRET_KEY اگر ست نباشد، در پروداکشن یک کلیدِ تصادفیِ واقعی ساخته و در .django_secret_key نگه داشته میشود (۰۶۰۰، خارج از گیت). همانقدر غیرقابلِ حدس، بدونِ یک قدمِ دستیِ دیگر؛ و چون روی دیسک میماند، ریاستارت نشستهای کاربران را باطل نمیکند. CACHE_URL نبودن دیگر مانعِ بالا آمدن نیست. مشترک نبودنِ کش بینِ workerها یک ضعفِ واقعیست ولی دلیلِ خوبی برای خوابیدنِ کلِ سرویس نیست؛ حالا بالا میآید و /api/health صریحاً هشدار میدهد. تستِ مربوطه هم از «باید بالا نیاید» به «باید بالا بیاید ولی بیصدا نماند» تغییر کرد. متغیرِ محیطی همچنان بر همهی این پیشفرضها اولویت دارد. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,3 +8,6 @@ db.sqlite3
|
|||||||
# کتابهای درسی: خودِ PDFها در گیت نمیآیند (حجیماند)
|
# کتابهای درسی: خودِ PDFها در گیت نمیآیند (حجیماند)
|
||||||
data/textbooks/*
|
data/textbooks/*
|
||||||
!data/textbooks/README.md
|
!data/textbooks/README.md
|
||||||
|
|
||||||
|
# کلیدِ امضای تولیدشدهی خودکار (هرگز کامیت نشود)
|
||||||
|
.django_secret_key
|
||||||
|
|||||||
@@ -37,21 +37,52 @@ except ModuleNotFoundError:
|
|||||||
# Defaults to False; must be explicitly enabled via env for local dev.
|
# Defaults to False; must be explicitly enabled via env for local dev.
|
||||||
DEBUG = os.environ.get('DJANGO_DEBUG', '') == '1'
|
DEBUG = os.environ.get('DJANGO_DEBUG', '') == '1'
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# کلیدِ امضا. ترتیبِ اولویت: متغیرِ محیطی، بعد فایلِ تولیدشدهی کنارِ پروژه.
|
||||||
# No hardcoded fallback when DEBUG=False — fails closed at startup instead.
|
#
|
||||||
|
# چرا فایل و نه خطای راهاندازی: قبلاً با DEBUG خاموش و بدونِ این متغیر، اپ
|
||||||
|
# اصلاً بالا نمیآمد و از بیرون فقط 502 دیده میشد. کسی که سرور را بالا میآورد
|
||||||
|
# باید حدس میزد چه چیزی کم است. حالا اولین اجرا خودش یک کلیدِ تصادفیِ واقعی
|
||||||
|
# میسازد و در .django_secret_key (خارج از گیت) نگه میدارد — همانقدر امن،
|
||||||
|
# بدونِ یک قدمِ دستیِ دیگر. متغیرِ محیطی همچنان بر آن اولویت دارد.
|
||||||
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
|
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
|
||||||
if not SECRET_KEY:
|
if not SECRET_KEY:
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
SECRET_KEY = 'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss'
|
SECRET_KEY = 'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss'
|
||||||
else:
|
else:
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
_key_file = BASE_DIR / '.django_secret_key'
|
||||||
|
try:
|
||||||
|
SECRET_KEY = _key_file.read_text().strip()
|
||||||
|
except OSError:
|
||||||
|
SECRET_KEY = ''
|
||||||
|
if not SECRET_KEY:
|
||||||
|
SECRET_KEY = secrets.token_urlsafe(64)
|
||||||
|
try:
|
||||||
|
_key_file.write_text(SECRET_KEY)
|
||||||
|
_key_file.chmod(0o600)
|
||||||
|
except OSError as exc:
|
||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured(
|
||||||
'DJANGO_SECRET_KEY environment variable is not set. '
|
f'DJANGO_SECRET_KEY ست نیست و نوشتنِ {_key_file} هم ممکن نشد ({exc}). '
|
||||||
'A predictable fallback secret key cannot be used when DEBUG=False.'
|
'یا متغیر را ست کنید یا اجازهی نوشتن در پوشهی پروژه بدهید.'
|
||||||
)
|
) from exc
|
||||||
|
|
||||||
ALLOWED_HOSTS = [h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h]
|
# دامنههای شناختهشدهی خودمان پیشفرضاند تا یک متغیرِ فراموششده باعثِ
|
||||||
|
# DisallowedHost نشود. متغیرِ محیطی، اگر ست باشد، کاملاً جایگزینشان میکند.
|
||||||
|
DEFAULT_ALLOWED_HOSTS = [
|
||||||
|
'api.math.tadrisino2.ir',
|
||||||
|
'math.tadrisino2.ir',
|
||||||
|
'localhost', '127.0.0.1',
|
||||||
|
]
|
||||||
|
ALLOWED_HOSTS = [
|
||||||
|
h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h
|
||||||
|
] or DEFAULT_ALLOWED_HOSTS
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o]
|
# لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود
|
||||||
|
# «CSRF verification failed» میدهد و کسی نمیفهمد چرا.
|
||||||
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
|
o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o
|
||||||
|
] or ['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir']
|
||||||
|
|
||||||
# --- CORS -------------------------------------------------------------------
|
# --- CORS -------------------------------------------------------------------
|
||||||
# فرانتاند (Vite/React) از یک origin دیگر صدا میزند، پس بدونِ این هدرها مرورگر
|
# فرانتاند (Vite/React) از یک origin دیگر صدا میزند، پس بدونِ این هدرها مرورگر
|
||||||
@@ -212,12 +243,15 @@ elif DEBUG:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
raise ImproperlyConfigured(
|
# بدونِ Redis محدودیتِ نرخ بینِ workerها مشترک نیست — ضعف است، ولی دلیلِ
|
||||||
'CACHE_URL environment variable is not set. '
|
# خوبی برای بالا نیامدنِ کلِ سرویس نیست. قبلاً اینجا استثنا پرتاب میشد و
|
||||||
'A shared cache is required when DEBUG=False, since rate limits '
|
# نتیجهاش از بیرون فقط 502 بود، بدونِ هیچ سرنخی. حالا بالا میآید و
|
||||||
'(OTP throttles and the phone-based login limit) rely on a cache '
|
# /api/health صریحاً هشدار میدهد.
|
||||||
'shared across all workers/instances; LocMemCache cannot be used in production.'
|
CACHES = {
|
||||||
)
|
'default': {
|
||||||
|
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
@@ -325,6 +359,7 @@ REST_FRAMEWORK = {
|
|||||||
'purchase': '10/hour',
|
'purchase': '10/hour',
|
||||||
'token_refresh': '30/hour',
|
'token_refresh': '30/hour',
|
||||||
'question_answer': '60/min',
|
'question_answer': '60/min',
|
||||||
|
'question_report': '20/hour',
|
||||||
'lesson_complete': '10/hour',
|
'lesson_complete': '10/hour',
|
||||||
'support_create': '30/hour',
|
'support_create': '30/hour',
|
||||||
'offer_redeem': '10/hour',
|
'offer_redeem': '10/hour',
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ from config.middleware import APIRequestLoggingMiddleware
|
|||||||
|
|
||||||
class SecretKeyFailClosedTests(SimpleTestCase):
|
class SecretKeyFailClosedTests(SimpleTestCase):
|
||||||
"""
|
"""
|
||||||
تستهای متمرکز روی این فیکس: SECRET_KEY دیگه در پروداکشن (DEBUG=False)
|
SECRET_KEY در پروداکشن هرگز دیفالتِ قابلحدسزدن ندارد — این هنوز برقرار است.
|
||||||
دیفالتِ هاردکد/قابلحدسزدن نداره. اگه DJANGO_SECRET_KEY ست نشده باشه،
|
|
||||||
settings.py باید fail-closed بشه (خطای صریح موقعِ import)، نه اینکه با
|
آنچه عوض شد: قبلاً نبودنِ DJANGO_SECRET_KEY یعنی اپ اصلاً بالا نمیآمد، و
|
||||||
یک fallback شناختهشده بالا بیاد. برای DEBUG=True، fallbackِ محلیِ قبلی
|
نتیجهاش از بیرون فقط 502 بود بدونِ هیچ سرنخی؛ کسی که سرور را بالا میآورد
|
||||||
باید دستنخورده بمونه.
|
باید حدس میزد چه چیزی کم است. حالا اولین اجرا یک کلیدِ تصادفیِ واقعی
|
||||||
|
میسازد و در فایلی کنارِ پروژه نگه میدارد — همانقدر غیرقابلِ حدس، بدونِ
|
||||||
|
یک قدمِ دستیِ دیگر. چیزی که باید همچنان تضمین شود این است که آن کلید
|
||||||
|
تصادفی باشد، نه رشتهی هاردکدِ حالتِ توسعه.
|
||||||
|
|
||||||
مثل SecretDefaultsFailClosedTests در payment/tests.py، چون SECRET_KEY
|
مثل SecretDefaultsFailClosedTests در payment/tests.py، چون SECRET_KEY
|
||||||
فقط یکبار موقعِ import محاسبه میشه (نه چیزی که override_settings
|
فقط یکبار موقعِ import محاسبه میشه (نه چیزی که override_settings
|
||||||
@@ -44,12 +47,35 @@ class SecretKeyFailClosedTests(SimpleTestCase):
|
|||||||
from config import settings as settings_module
|
from config import settings as settings_module
|
||||||
importlib.reload(settings_module)
|
importlib.reload(settings_module)
|
||||||
|
|
||||||
def test_production_missing_secret_key_fails_closed(self):
|
def test_production_without_the_env_var_generates_a_real_random_key(self):
|
||||||
with self.assertRaises(ImproperlyConfigured):
|
settings_module = self._reload_settings_with_env(
|
||||||
self._reload_settings_with_env(
|
|
||||||
env_overrides={'DJANGO_DEBUG': ''},
|
env_overrides={'DJANGO_DEBUG': ''},
|
||||||
env_removed={'DJANGO_SECRET_KEY'},
|
env_removed={'DJANGO_SECRET_KEY'},
|
||||||
)
|
)
|
||||||
|
key = settings_module.SECRET_KEY
|
||||||
|
self.addCleanup(
|
||||||
|
lambda: (settings_module.BASE_DIR / '.django_secret_key').unlink(missing_ok=True)
|
||||||
|
)
|
||||||
|
self.assertFalse(settings_module.DEBUG)
|
||||||
|
self.assertNotEqual(
|
||||||
|
key,
|
||||||
|
'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss',
|
||||||
|
'کلیدِ حالتِ توسعه هرگز نباید در پروداکشن استفاده شود',
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(key), 50)
|
||||||
|
|
||||||
|
def test_the_generated_key_survives_a_restart(self):
|
||||||
|
"""اگر هر بار کلیدِ تازه ساخته شود، همهی نشستها با هر ریاستارت باطل میشوند."""
|
||||||
|
first = self._reload_settings_with_env(
|
||||||
|
env_overrides={'DJANGO_DEBUG': ''}, env_removed={'DJANGO_SECRET_KEY'},
|
||||||
|
).SECRET_KEY
|
||||||
|
settings_module = self._reload_settings_with_env(
|
||||||
|
env_overrides={'DJANGO_DEBUG': ''}, env_removed={'DJANGO_SECRET_KEY'},
|
||||||
|
)
|
||||||
|
self.addCleanup(
|
||||||
|
lambda: (settings_module.BASE_DIR / '.django_secret_key').unlink(missing_ok=True)
|
||||||
|
)
|
||||||
|
self.assertEqual(first, settings_module.SECRET_KEY)
|
||||||
|
|
||||||
def test_production_configured_secret_key_used(self):
|
def test_production_configured_secret_key_used(self):
|
||||||
# CACHE_URL هم باید ست بشه، وگرنه با DEBUG=False همون
|
# CACHE_URL هم باید ست بشه، وگرنه با DEBUG=False همون
|
||||||
@@ -79,10 +105,13 @@ class SecretKeyFailClosedTests(SimpleTestCase):
|
|||||||
|
|
||||||
class CacheUrlFailClosedTests(SimpleTestCase):
|
class CacheUrlFailClosedTests(SimpleTestCase):
|
||||||
"""
|
"""
|
||||||
🔒 تستِ متمرکز روی فیکسِ CACHE_URL: در پروداکشن (DEBUG=False)، اگه
|
نبودنِ CACHE_URL در پروداکشن یک ضعفِ واقعیست: LocMemCache بینِ workerها
|
||||||
CACHE_URL ست نشده باشه، settings.py باید fail-closed بشه (خطای صریح
|
مشترک نیست و throttleها را کماثر میکند.
|
||||||
موقعِ import)، نه fallback خاموش به LocMemCache (که بینِ worker/instance
|
|
||||||
ها مشترک نیست و throttle/rate-limit ها رو بیاثر میکنه).
|
ولی دلیلِ خوبی برای بالا نیامدنِ کلِ سرویس نیست — قبلاً همینجا استثنا
|
||||||
|
پرتاب میشد و نتیجهاش از بیرون فقط 502 بود. حالا سرویس بالا میآید و
|
||||||
|
/api/health صریحاً هشدار میدهد. چیزی که باید تضمین شود این است که این
|
||||||
|
ضعف **بیصدا** نماند.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
allow_database_queries = True
|
allow_database_queries = True
|
||||||
@@ -100,12 +129,21 @@ class CacheUrlFailClosedTests(SimpleTestCase):
|
|||||||
from config import settings as settings_module
|
from config import settings as settings_module
|
||||||
importlib.reload(settings_module)
|
importlib.reload(settings_module)
|
||||||
|
|
||||||
def test_production_missing_cache_url_fails_closed(self):
|
def test_production_without_cache_url_boots_but_is_not_silent(self):
|
||||||
with self.assertRaises(ImproperlyConfigured):
|
settings_module = self._reload_settings_with_env(
|
||||||
self._reload_settings_with_env(
|
|
||||||
env_overrides={'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'prod-secret-xyz'},
|
env_overrides={'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'prod-secret-xyz'},
|
||||||
env_removed={'CACHE_URL'},
|
env_removed={'CACHE_URL'},
|
||||||
)
|
)
|
||||||
|
self.assertIn('LocMem', settings_module.CACHES['default']['BACKEND'])
|
||||||
|
|
||||||
|
# همان ضعف باید در /api/health دیده شود، وگرنه بیصدا میماند.
|
||||||
|
from config import health
|
||||||
|
|
||||||
|
with self.settings(DEBUG=False,
|
||||||
|
CACHES=settings_module.CACHES,
|
||||||
|
DEV_MASTER_OTP=None):
|
||||||
|
warnings = ' '.join(health._warnings())
|
||||||
|
self.assertIn('کش', warnings)
|
||||||
|
|
||||||
def test_debug_mode_missing_cache_url_uses_locmem_fallback(self):
|
def test_debug_mode_missing_cache_url_uses_locmem_fallback(self):
|
||||||
settings_module = self._reload_settings_with_env(
|
settings_module = self._reload_settings_with_env(
|
||||||
|
|||||||
Reference in New Issue
Block a user