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:
2026-09-13 21:08:00 +03:30
parent 9cee574fa9
commit 67361f6711
3 changed files with 111 additions and 35 deletions

3
.gitignore vendored
View File

@@ -8,3 +8,6 @@ db.sqlite3
# کتاب‌های درسی: خودِ PDFها در گیت نمی‌آیند (حجیم‌اند) # کتاب‌های درسی: خودِ PDFها در گیت نمی‌آیند (حجیم‌اند)
data/textbooks/* data/textbooks/*
!data/textbooks/README.md !data/textbooks/README.md
# کلیدِ امضای تولیدشده‌ی خودکار (هرگز کامیت نشود)
.django_secret_key

View File

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

View File

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