From 3156bcbbf3cf0f0c55ae55aed44c1eb772b1bb69 Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 20 Sep 2026 12:36:45 +0330 Subject: [PATCH] stop counting the frontend developer's port numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit فهرستِ ثابتِ CORS (۳۰۰۰ و ۵۱۷۳) روزی شکست که Vite دید ۵۱۷۳ اشغال است و بی‌سروصدا رفت روی ۵۱۷۴ — رفتارِ پیش‌فرضِ خودش. فرانت‌کار خطای CORS گرفت، از سمتِ سرور هیچ ردی در هیچ لاگی نبود (خطا فقط در کنسولِ مرورگر دیده می‌شود)، و چون هم‌زمان با تغییراتِ بک‌اند شد به اشتباه گردنِ ما افتاد. هزینه‌اش یک ویرایشِ .env و یک ری‌استارتِ پروداکشن بود، برای چیزی که اصلاً به پروداکشن ربطی نداشت. به‌جای شمردنِ پورت‌ها، الگوی «هر پورتی روی خودِ ماشینِ توسعه‌دهنده» پذیرفته شد. سطحِ حمله اضافه نمی‌کند: توکنِ ما در هدر است نه کوکی و CORS_ALLOW_CREDENTIALS خاموش، پس صفحه‌ای روی localhostِ مهاجم هیچ اعتبارنامه‌ای از مرورگرِ قربانی نمی‌گیرد؛ CORS هم اصلاً سدِ سرور-به-سرور نیست. تست‌ها هم پوشش می‌دهند که localhost.evil.com و math.tadrisino2.ir.evil.com همچنان رد شوند. Co-Authored-By: Claude Opus 5 --- config/settings.py | 19 ++++++++++++++++-- config/tests.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/config/settings.py b/config/settings.py index 97193bd..153dfcf 100644 --- a/config/settings.py +++ b/config/settings.py @@ -134,10 +134,25 @@ SESSION_COOKIE_HTTPONLY = True # در کنسولِ مرورگر دیده می‌شود. CORS_DEFAULT_ORIGINS = [ 'https://math.tadrisino2.ir', # فرانتِ مستقر - 'http://localhost:3000', 'http://127.0.0.1:3000', - 'http://localhost:5173', 'http://127.0.0.1:5173', # Vite/CRA محلی ] CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS) + +# ⚠️ پورتِ سرورِ توسعه را نباید فهرست کرد. فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی +# شکست که Vite دید ۵۱۷۳ اشغال است و بی‌سروصدا رفت روی ۵۱۷۴ — رفتارِ پیش‌فرضِ +# خودش. آن‌وقت فرانت‌کار خطای CORS گرفت، از سمتِ سرور هیچ ردی در هیچ لاگی +# نبود، و چون هم‌زمان با تغییراتِ ما شد، به اشتباه گردنِ بک‌اند افتاد. +# هر بار که این تکرار شود یعنی یک ویرایشِ .env و یک ری‌استارتِ پروداکشن برای +# چیزی که اصلاً به پروداکشن ربطی ندارد. +# +# پس به‌جای شمردنِ پورت‌ها، الگوی «هر پورتی روی خودِ ماشینِ توسعه‌دهنده» را +# می‌پذیریم. این سطحِ حمله اضافه نمی‌کند: احرازِ هویتِ ما توکنِ Bearer در هدر +# است و `CORS_ALLOW_CREDENTIALS` خاموش، پس صفحه‌ای که روی localhostِ یک مهاجم +# باز است هیچ اعتبارنامه‌ای از مرورگرِ قربانی به دست نمی‌آورد؛ CORS هم اصلاً +# سدِ سرور-به-سرور نیست. +CORS_ALLOWED_ORIGIN_REGEXES = [ + r'^http://localhost:\d+$', + r'^http://127\.0\.0\.1:\d+$', +] CORS_ALLOW_CREDENTIALS = False # فقط مسیرهای API؛ /admin/ و فایل‌های استاتیک لازم نیست از origin دیگر خوانده شوند. CORS_URLS_REGEX = r'^/api/.*$' diff --git a/config/tests.py b/config/tests.py index f3c6f60..9f3aaae 100644 --- a/config/tests.py +++ b/config/tests.py @@ -403,3 +403,51 @@ class SecureCookieTests(SimpleTestCase): """بدونِ این، کوکیِ امن پشتِ nginx هرگز ست نمی‌شود و ورود می‌شکند.""" self.assertEqual(settings.SECURE_PROXY_SSL_HEADER, ('HTTP_X_FORWARDED_PROTO', 'https')) + + +class CorsDevPortTests(TestCase): + """ + پورتِ سرورِ توسعه نباید در فهرستِ CORS شمرده شود. + + فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی شکست که Vite دید ۵۱۷۳ اشغال است و + بی‌سروصدا رفت روی ۵۱۷۴. فرانت‌کار خطای CORS گرفت، از سمتِ سرور هیچ ردی در + هیچ لاگی نبود، و چون هم‌زمان با تغییراتِ بک‌اند شد به اشتباه گردنِ ما افتاد. + هزینه‌اش یک ویرایشِ .env و یک ری‌استارتِ پروداکشن بود، برای چیزی که اصلاً + به پروداکشن ربطی نداشت. + """ + + def _preflight(self, origin): + return self.client.options( + '/api/grades', + HTTP_ORIGIN=origin, + HTTP_ACCESS_CONTROL_REQUEST_METHOD='GET', + ) + + def test_any_localhost_port_is_allowed_so_a_new_dev_port_never_breaks_again(self): + for origin in ('http://localhost:5173', 'http://localhost:5174', + 'http://localhost:3000', 'http://127.0.0.1:8080'): + with self.subTest(origin=origin): + resp = self._preflight(origin) + self.assertEqual( + resp.headers.get('access-control-allow-origin'), origin) + + def test_the_deployed_frontend_origin_still_works(self): + resp = self._preflight('https://math.tadrisino2.ir') + self.assertEqual(resp.headers.get('access-control-allow-origin'), + 'https://math.tadrisino2.ir') + + def test_a_stranger_origin_is_still_refused(self): + # الگو فقط localhost است، نه هر دامنه‌ای؛ و «localhost» به‌عنوانِ + # زیررشته در دامنه‌ی دیگری نباید رد شود. + for origin in ('https://evil.example.com', + 'http://localhost.evil.com', + 'https://math.tadrisino2.ir.evil.com'): + with self.subTest(origin=origin): + resp = self._preflight(origin) + self.assertIsNone(resp.headers.get('access-control-allow-origin')) + + def test_credentials_stay_off(self): + # توکنِ ما در هدر است نه کوکی؛ روشن کردنِ این فقط سطحِ حمله اضافه می‌کند + # و پذیرشِ هر پورتِ localhost را واقعاً خطرناک می‌کرد. + from django.conf import settings + self.assertFalse(settings.CORS_ALLOW_CREDENTIALS)