From 67361f67119afb85bf9b4022d5957a6338225acd Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 13 Sep 2026 21:08:00 +0330 Subject: [PATCH] boot with sane defaults instead of refusing to start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit سه بار پشتِ سر هم همین الگو تکرار شد: یک متغیرِ محیطی ست نبود، جنگو موقعِ بالا آمدن مرد، و از بیرون فقط «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 --- .gitignore | 3 ++ config/settings.py | 63 ++++++++++++++++++++++++++++-------- config/tests.py | 80 ++++++++++++++++++++++++++++++++++------------ 3 files changed, 111 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index e187e09..498fc90 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ db.sqlite3 # کتاب‌های درسی: خودِ PDFها در گیت نمی‌آیند (حجیم‌اند) data/textbooks/* !data/textbooks/README.md + +# کلیدِ امضای تولیدشده‌ی خودکار (هرگز کامیت نشود) +.django_secret_key diff --git a/config/settings.py b/config/settings.py index 71e9e15..35bedbe 100644 --- a/config/settings.py +++ b/config/settings.py @@ -37,21 +37,52 @@ except ModuleNotFoundError: # Defaults to False; must be explicitly enabled via env for local dev. 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') if not SECRET_KEY: if DEBUG: SECRET_KEY = 'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss' else: - raise ImproperlyConfigured( - 'DJANGO_SECRET_KEY environment variable is not set. ' - 'A predictable fallback secret key cannot be used when DEBUG=False.' - ) + import secrets -ALLOWED_HOSTS = [h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h] + _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( + f'DJANGO_SECRET_KEY ست نیست و نوشتنِ {_key_file} هم ممکن نشد ({exc}). ' + 'یا متغیر را ست کنید یا اجازه‌ی نوشتن در پوشه‌ی پروژه بدهید.' + ) from exc -CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o] +# دامنه‌های شناخته‌شده‌ی خودمان پیش‌فرض‌اند تا یک متغیرِ فراموش‌شده باعثِ +# 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 + +# لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ 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 ------------------------------------------------------------------- # فرانت‌اند (Vite/React) از یک origin دیگر صدا می‌زند، پس بدونِ این هدرها مرورگر @@ -212,12 +243,15 @@ elif DEBUG: } } else: - raise ImproperlyConfigured( - 'CACHE_URL environment variable is not set. ' - 'A shared cache is required when DEBUG=False, since rate limits ' - '(OTP throttles and the phone-based login limit) rely on a cache ' - 'shared across all workers/instances; LocMemCache cannot be used in production.' - ) + # بدونِ Redis محدودیتِ نرخ بینِ workerها مشترک نیست — ضعف است، ولی دلیلِ + # خوبی برای بالا نیامدنِ کلِ سرویس نیست. قبلاً اینجا استثنا پرتاب می‌شد و + # نتیجه‌اش از بیرون فقط 502 بود، بدونِ هیچ سرنخی. حالا بالا می‌آید و + # /api/health صریحاً هشدار می‌دهد. + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + } + } # Password validation @@ -325,6 +359,7 @@ REST_FRAMEWORK = { 'purchase': '10/hour', 'token_refresh': '30/hour', 'question_answer': '60/min', + 'question_report': '20/hour', 'lesson_complete': '10/hour', 'support_create': '30/hour', 'offer_redeem': '10/hour', diff --git a/config/tests.py b/config/tests.py index 4f131b9..4be3e00 100644 --- a/config/tests.py +++ b/config/tests.py @@ -15,11 +15,14 @@ from config.middleware import APIRequestLoggingMiddleware class SecretKeyFailClosedTests(SimpleTestCase): """ - تست‌های متمرکز روی این فیکس: SECRET_KEY دیگه در پروداکشن (DEBUG=False) - دیفالتِ هاردکد/قابل‌حدس‌زدن نداره. اگه DJANGO_SECRET_KEY ست نشده باشه، - settings.py باید fail-closed بشه (خطای صریح موقعِ import)، نه اینکه با - یک fallback شناخته‌شده بالا بیاد. برای DEBUG=True، fallbackِ محلیِ قبلی - باید دست‌نخورده بمونه. + SECRET_KEY در پروداکشن هرگز دیفالتِ قابل‌حدس‌زدن ندارد — این هنوز برقرار است. + + آنچه عوض شد: قبلاً نبودنِ DJANGO_SECRET_KEY یعنی اپ اصلاً بالا نمی‌آمد، و + نتیجه‌اش از بیرون فقط 502 بود بدونِ هیچ سرنخی؛ کسی که سرور را بالا می‌آورد + باید حدس می‌زد چه چیزی کم است. حالا اولین اجرا یک کلیدِ تصادفیِ واقعی + می‌سازد و در فایلی کنارِ پروژه نگه می‌دارد — همان‌قدر غیرقابلِ حدس، بدونِ + یک قدمِ دستیِ دیگر. چیزی که باید همچنان تضمین شود این است که آن کلید + تصادفی باشد، نه رشته‌ی هاردکدِ حالتِ توسعه. مثل SecretDefaultsFailClosedTests در payment/tests.py، چون SECRET_KEY فقط یک‌بار موقعِ import محاسبه می‌شه (نه چیزی که override_settings @@ -44,12 +47,35 @@ class SecretKeyFailClosedTests(SimpleTestCase): from config import settings as settings_module importlib.reload(settings_module) - def test_production_missing_secret_key_fails_closed(self): - with self.assertRaises(ImproperlyConfigured): - self._reload_settings_with_env( - env_overrides={'DJANGO_DEBUG': ''}, - env_removed={'DJANGO_SECRET_KEY'}, - ) + def test_production_without_the_env_var_generates_a_real_random_key(self): + settings_module = self._reload_settings_with_env( + env_overrides={'DJANGO_DEBUG': ''}, + 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): # CACHE_URL هم باید ست بشه، وگرنه با DEBUG=False همون @@ -79,10 +105,13 @@ class SecretKeyFailClosedTests(SimpleTestCase): class CacheUrlFailClosedTests(SimpleTestCase): """ - 🔒 تستِ متمرکز روی فیکسِ CACHE_URL: در پروداکشن (DEBUG=False)، اگه - CACHE_URL ست نشده باشه، settings.py باید fail-closed بشه (خطای صریح - موقعِ import)، نه fallback خاموش به LocMemCache (که بینِ worker/instance - ها مشترک نیست و throttle/rate-limit ها رو بی‌اثر می‌کنه). + نبودنِ CACHE_URL در پروداکشن یک ضعفِ واقعی‌ست: LocMemCache بینِ workerها + مشترک نیست و throttleها را کم‌اثر می‌کند. + + ولی دلیلِ خوبی برای بالا نیامدنِ کلِ سرویس نیست — قبلاً همین‌جا استثنا + پرتاب می‌شد و نتیجه‌اش از بیرون فقط 502 بود. حالا سرویس بالا می‌آید و + /api/health صریحاً هشدار می‌دهد. چیزی که باید تضمین شود این است که این + ضعف **بی‌صدا** نماند. """ allow_database_queries = True @@ -100,12 +129,21 @@ class CacheUrlFailClosedTests(SimpleTestCase): from config import settings as settings_module importlib.reload(settings_module) - def test_production_missing_cache_url_fails_closed(self): - with self.assertRaises(ImproperlyConfigured): - self._reload_settings_with_env( - env_overrides={'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'prod-secret-xyz'}, - env_removed={'CACHE_URL'}, - ) + def test_production_without_cache_url_boots_but_is_not_silent(self): + settings_module = self._reload_settings_with_env( + env_overrides={'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'prod-secret-xyz'}, + 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): settings_module = self._reload_settings_with_env(