stop counting the frontend developer's port numbers

فهرستِ ثابتِ CORS (۳۰۰۰ و ۵۱۷۳) روزی شکست که Vite دید ۵۱۷۳ اشغال است و
بی‌سروصدا رفت روی ۵۱۷۴ — رفتارِ پیش‌فرضِ خودش. فرانت‌کار خطای CORS گرفت، از
سمتِ سرور هیچ ردی در هیچ لاگی نبود (خطا فقط در کنسولِ مرورگر دیده می‌شود)، و
چون هم‌زمان با تغییراتِ بک‌اند شد به اشتباه گردنِ ما افتاد. هزینه‌اش یک ویرایشِ
.env و یک ری‌استارتِ پروداکشن بود، برای چیزی که اصلاً به پروداکشن ربطی نداشت.

به‌جای شمردنِ پورت‌ها، الگوی «هر پورتی روی خودِ ماشینِ توسعه‌دهنده» پذیرفته شد.
سطحِ حمله اضافه نمی‌کند: توکنِ ما در هدر است نه کوکی و CORS_ALLOW_CREDENTIALS
خاموش، پس صفحه‌ای روی localhostِ مهاجم هیچ اعتبارنامه‌ای از مرورگرِ قربانی
نمی‌گیرد؛ CORS هم اصلاً سدِ سرور-به-سرور نیست.

تست‌ها هم پوشش می‌دهند که localhost.evil.com و math.tadrisino2.ir.evil.com
همچنان رد شوند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 12:36:45 +03:30
parent d15a28799a
commit 3156bcbbf3
2 changed files with 65 additions and 2 deletions

View File

@@ -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/.*$'

View File

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