Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks
- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and curriculum import need add/delete; content browser needs view_question; guest device ids are shown to superusers only. - Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles. - Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409; orders snapshot credits/hearts/days at creation (payment.0011). - Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker refuses the answer (not stored, not cached); a disconnect saves the partial answer; only KNOWN_COLLECTIONS are searched. - Infra: secret-key file is created atomically (workers no longer split keys); no default INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400; ticket text capped at 5000. - docs/security-before-production.md: remaining open items 24-31 and the guard tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,21 +53,49 @@ if not SECRET_KEY:
|
||||
else:
|
||||
import secrets
|
||||
|
||||
import time
|
||||
|
||||
# سه workerِ gunicorn همزمان به اینجا میرسند. فایل فقط یک بار و اتمی ساخته میشود
|
||||
# (O_EXCL، و نوشتن در فایلِ موقت + rename)، و بقیه همان را میخوانند؛ در غیرِ این صورت هر
|
||||
# worker کلیدِ خودش را میساخت و توکن فقط روی یکی از آنها معتبر بود. سنجیده با سه
|
||||
# پروسهی همزمان، نه با gunicornِ واقعی.
|
||||
_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)
|
||||
_lock_file = BASE_DIR / '.django_secret_key.lock'
|
||||
SECRET_KEY = ''
|
||||
for _attempt in range(50):
|
||||
try:
|
||||
_key_file.write_text(SECRET_KEY)
|
||||
_key_file.chmod(0o600)
|
||||
SECRET_KEY = _key_file.read_text().strip()
|
||||
except FileNotFoundError:
|
||||
SECRET_KEY = ''
|
||||
except OSError as exc:
|
||||
raise ImproperlyConfigured(f'خواندنِ {_key_file} ممکن نشد ({exc}).') from exc
|
||||
if SECRET_KEY:
|
||||
break
|
||||
try:
|
||||
_fd = os.open(_lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
except FileExistsError:
|
||||
time.sleep(0.1) # پروسهی دیگری در حالِ ساختنِ کلید است
|
||||
continue
|
||||
except OSError as exc:
|
||||
raise ImproperlyConfigured(
|
||||
f'DJANGO_SECRET_KEY ست نیست و نوشتنِ {_key_file} هم ممکن نشد ({exc}). '
|
||||
f'DJANGO_SECRET_KEY ست نیست و ساختنِ {_key_file} هم ممکن نشد ({exc}). '
|
||||
'یا متغیر را ست کنید یا اجازهی نوشتن در پوشهی پروژه بدهید.'
|
||||
) from exc
|
||||
try:
|
||||
os.close(_fd)
|
||||
if not _key_file.exists():
|
||||
_tmp = BASE_DIR / '.django_secret_key.tmp'
|
||||
_tmp_fd = os.open(_tmp, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(_tmp_fd, 'w') as fh:
|
||||
fh.write(secrets.token_urlsafe(64))
|
||||
os.replace(_tmp, _key_file)
|
||||
finally:
|
||||
_lock_file.unlink(missing_ok=True)
|
||||
if not SECRET_KEY:
|
||||
raise ImproperlyConfigured(
|
||||
f'کلیدِ امضا ساخته نشد: {_lock_file} مانده است. اگر هیچ پروسهای در حالِ اجرا نیست، '
|
||||
'آن را پاک کنید و دوباره راهاندازی کنید.'
|
||||
)
|
||||
|
||||
def _env_list(name: str) -> list[str]:
|
||||
return [v.strip() for v in os.environ.get(name, '').split(',') if v.strip()]
|
||||
@@ -607,6 +635,12 @@ SPECTACULAR_SETTINGS = {
|
||||
'filter': True,
|
||||
'persistAuthorization': True,
|
||||
},
|
||||
# نسخهی ثابت، نه @latest: این صفحهها روی همان دامنهی /admin/ اجرا میشوند، و نسخهی منتشرشده
|
||||
# در npm عوضشدنی نیست؛ با @latest هر نسخهی آلودهی تازهی این بستهها در مرورگرِ کارمند اجرا میشد.
|
||||
# SRI ندارد. ارتقا یعنی عوض کردنِ همین دو عدد.
|
||||
'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1',
|
||||
'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/favicon-32x32.png',
|
||||
'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@2.5.4',
|
||||
}
|
||||
|
||||
# اعتبار توکن دسترسی (ثانیه) و رفرشتوکن (روز)
|
||||
@@ -702,6 +736,11 @@ def _parse_test_login_phones(raw: str) -> dict[str, str]:
|
||||
|
||||
TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES', ''))
|
||||
|
||||
# مهمانهای نمایشی که با حذفِ حساب پاک نمیشوند (همان کاری که TEST_LOGIN_PHONES برای شمارهها میکند).
|
||||
# پیشفرض: حسابِ مهمانِ تستِ فرانت (کاربرِ ۶ روی سرور)، که شناسهاش در ریپو و مستندات آمده و هر کسی
|
||||
# میتواند با آن وارد شود. با پاک شدنِ دادهی دمو پیش از کاربرِ واقعی، این پیشفرض هم برداشته شود.
|
||||
DEMO_GUEST_DEVICE_IDS = _env_list('DEMO_GUEST_DEVICE_IDS') or ['frontend-test-device']
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# OTP SMS delivery (accounts/services.py: send_otp_sms)
|
||||
# -----------------------------------------------------------------------
|
||||
@@ -745,10 +784,9 @@ ADMIN_LOGIN_MAX_ATTEMPTS = 10
|
||||
# POST /api/payment/orders باید فقط سرور-به-سرور (از subscription) صدا زده شود؛
|
||||
# کلاینت باید هدر X-Internal-Key برابر همین مقدار بفرستد. بدون env در
|
||||
# پروداکشن مقدار خالی میماند و HasInternalServiceKey صراحتاً رد میکند.
|
||||
INTERNAL_SERVICE_KEY = os.environ.get(
|
||||
'INTERNAL_SERVICE_KEY',
|
||||
'dev-internal-service-key-change-me' if DEBUG else '',
|
||||
)
|
||||
# هیچ پیشفرضی، حتی در DEBUG: رشتهی پیشفرضِ داخلِ ریپو هر لپتاپِ توسعهای را که روی شبکه
|
||||
# باز است به ingest در Qdrant راه میداد.
|
||||
INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', '')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.views import defaults as django_default_views
|
||||
from drf_spectacular.utils import extend_schema
|
||||
|
||||
from config.health import HealthView
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from drf_spectacular.views import (
|
||||
SpectacularAPIView,
|
||||
SpectacularRedocView,
|
||||
@@ -46,6 +47,13 @@ def handler500(request):
|
||||
return django_default_views.server_error(request)
|
||||
|
||||
|
||||
class ThrottledSchemaView(SpectacularAPIView):
|
||||
"""بیورود و بیکش: هر بار ~۵۶ms CPU و ۱۱۰KB؛ بیthrottle چند ده درخواست در ثانیه workerها را پر میکرد."""
|
||||
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = 'read'
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
|
||||
@@ -67,7 +75,7 @@ urlpatterns = [
|
||||
path('api/health/', HealthViewSlashAlias.as_view(), name='health-slash'),
|
||||
|
||||
# OpenAPI schema + Swagger UI + Redoc
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
path('api/schema/', ThrottledSchemaView.as_view(), name='schema'),
|
||||
# قالبِ خودمان فقط یک پوستهی راستبهچپ روی قالبِ drf-spectacular است؛
|
||||
# بدونش متنِ فارسی در یک بلوکِ LTR رندر میشود و نقطهی جمله و ترتیبِ
|
||||
# ستونهای جدول جابهجا میشوند. توضیح در خودِ قالب.
|
||||
|
||||
Reference in New Issue
Block a user