Oversized request body: 413 with a short warning instead of 500 and an ERROR traceback
Found on the live server (3 MB JSON to /api/auth/login answered 500). Django raises RequestDataTooBig for bodies over DATA_UPLOAD_MAX_MEMORY_SIZE and would answer 400, but our DRF exception handler turned every unknown exception into 500 + logger.exception, so each large request also wrote a full ERROR traceback. Other SuspiciousOperation subclasses now get Django's 400 as well. Tested: 2 tests (the 413 one fails without the change). Verified on the server after deploy. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ JSON. این هندلر اون حالت رو با traceback کامل لاگ می
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.core.exceptions import RequestDataTooBig, SuspiciousOperation
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import exception_handler as drf_exception_handler
|
from rest_framework.views import exception_handler as drf_exception_handler
|
||||||
|
|
||||||
@@ -19,11 +20,23 @@ def custom_exception_handler(exc, context):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
request = context.get('request')
|
request = context.get('request')
|
||||||
|
path = getattr(request, 'path', '')
|
||||||
|
|
||||||
|
# بدنهی بزرگتر از DATA_UPLOAD_MAX_MEMORY_SIZE (≈ ۲٫۵ مگابایت): Django خودش این را ۴۰۰ میکند، ولی
|
||||||
|
# چون DRF آن را نمیشناسد پیشتر اینجا «۵۰۰ + ERRORِ لاگ با traceback» میشد و هر درخواستِ بزرگ لاگ را پر
|
||||||
|
# میکرد (۲۰۲۶-۱۰-۰۷ روی سرورِ زنده دیده شد). باقیِ SuspiciousOperationها هم همان ۴۰۰ِ Django را میگیرند.
|
||||||
|
if isinstance(exc, RequestDataTooBig):
|
||||||
|
logger.warning('request_too_large path=%s', path)
|
||||||
|
return Response({'detail': 'حجمِ درخواست بیش از حدِ مجاز است.'}, status=413)
|
||||||
|
if isinstance(exc, SuspiciousOperation):
|
||||||
|
logger.warning('suspicious_operation path=%s type=%s', path, type(exc).__name__)
|
||||||
|
return Response({'detail': 'درخواستِ نامعتبر.'}, status=400)
|
||||||
|
|
||||||
user = getattr(request, 'user', None)
|
user = getattr(request, 'user', None)
|
||||||
user_id = user.id if getattr(user, 'is_authenticated', False) else None
|
user_id = user.id if getattr(user, 'is_authenticated', False) else None
|
||||||
logger.exception(
|
logger.exception(
|
||||||
'unhandled_exception path=%s user_id=%s',
|
'unhandled_exception path=%s user_id=%s',
|
||||||
getattr(request, 'path', ''), user_id,
|
path, user_id,
|
||||||
)
|
)
|
||||||
return Response(
|
return Response(
|
||||||
{'detail': 'خطای غیرمنتظرهای رخ داد؛ لطفاً بعداً دوباره تلاش کنید.'},
|
{'detail': 'خطای غیرمنتظرهای رخ داد؛ لطفاً بعداً دوباره تلاش کنید.'},
|
||||||
|
|||||||
31
config/tests_request_size.py
Normal file
31
config/tests_request_size.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
بدنهی درخواستِ بزرگ ۵۰۰ و ERRORِ لاگ نمیدهد (config/exceptions.py). روی سرورِ زنده، JSONِ ۳ مگابایتی به
|
||||||
|
/api/auth/login ۵۰۰ داده بود؛ ۲۰۰ کیلوبایت درست ۴۰۰ میداد.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(DATA_UPLOAD_MAX_MEMORY_SIZE=2_621_440)
|
||||||
|
class OversizedBodyTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def _post(self, path, size):
|
||||||
|
body = json.dumps({'phone': '09120000000', 'otp': '1' * size})
|
||||||
|
return APIClient().post(path, data=body, content_type='application/json')
|
||||||
|
|
||||||
|
def test_a_body_over_the_limit_is_413_with_a_persian_message(self):
|
||||||
|
for path in ('/api/auth/login', '/api/auth/otp/request'):
|
||||||
|
with self.assertNoLogs('config.exceptions', 'ERROR'):
|
||||||
|
response = self._post(path, 3_000_000)
|
||||||
|
self.assertEqual(response.status_code, 413, path)
|
||||||
|
self.assertIn('حجم', response.json()['detail'])
|
||||||
|
|
||||||
|
def test_a_large_but_allowed_body_still_gets_the_normal_validation_error(self):
|
||||||
|
response = self._post('/api/auth/login', 200_000)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertIn('otp', response.json())
|
||||||
@@ -271,6 +271,7 @@
|
|||||||
| پایانِ درس و پایانِ آزمونِ جزیره جوابِ ثبتشده به همهی سوالها میخواهند (۴۰۹ `answers_missing`)، روی جزیرهی قفل ۴۰۳ `node_locked` میدهند (همان قاعدهی نقشه)، جزیره یا درسِ بیسوال چیزی برای جوابدادن ندارد و اگر باز است بیامتیاز رد میشود (قفل بودنش هنوز چک میشود)، و تعدادِ سوالِ آزمون را کلاینت نمیتواند کمتر از آزمونِ استانداردِ ۴ تایی بگوید (ستارهی دروغین) | `content/tests_finish_requires_answers.py`، `content/tests_node_completion.py` |
|
| پایانِ درس و پایانِ آزمونِ جزیره جوابِ ثبتشده به همهی سوالها میخواهند (۴۰۹ `answers_missing`)، روی جزیرهی قفل ۴۰۳ `node_locked` میدهند (همان قاعدهی نقشه)، جزیره یا درسِ بیسوال چیزی برای جوابدادن ندارد و اگر باز است بیامتیاز رد میشود (قفل بودنش هنوز چک میشود)، و تعدادِ سوالِ آزمون را کلاینت نمیتواند کمتر از آزمونِ استانداردِ ۴ تایی بگوید (ستارهی دروغین) | `content/tests_finish_requires_answers.py`، `content/tests_node_completion.py` |
|
||||||
| کلیدِ امضای توکن تصادفی و فقط روی سرور است (`.django_secret_key`، ۶۰۰)؛ نه در `.env`، نه در یونیت؛ توکنِ ساختهشده با کلیدِ داخلِ ریپو `401` میگیرد؛ `DJANGO_DEBUG` هم در یونیت نیست | دستی (۲۰۲۶-۱۰-۰۷): توکنِ جعلی `200` ← `401`؛ تستِ واحد ندارد |
|
| کلیدِ امضای توکن تصادفی و فقط روی سرور است (`.django_secret_key`، ۶۰۰)؛ نه در `.env`، نه در یونیت؛ توکنِ ساختهشده با کلیدِ داخلِ ریپو `401` میگیرد؛ `DJANGO_DEBUG` هم در یونیت نیست | دستی (۲۰۲۶-۱۰-۰۷): توکنِ جعلی `200` ← `401`؛ تستِ واحد ندارد |
|
||||||
| با `CACHE_URL` ستشده، قطعیِ Redis API را ۵۰۰ نمیکند (fail-open، ERROR در لاگ، health ۵۰۳؛ شرح در `config/cache.py`). الان ست نیست | `config/tests_cache_resilience.py` (۱۱ تست، با redis-serverِ محلی که وسطِ کار میمیرد) |
|
| با `CACHE_URL` ستشده، قطعیِ Redis API را ۵۰۰ نمیکند (fail-open، ERROR در لاگ، health ۵۰۳؛ شرح در `config/cache.py`). الان ست نیست | `config/tests_cache_resilience.py` (۱۱ تست، با redis-serverِ محلی که وسطِ کار میمیرد) |
|
||||||
|
| بدنهٔ درخواست بزرگتر از ≈ ۲٫۵ مگابایت `413` میگیرد و ERRORِ لاگ نمیسازد (پیشتر `500` + traceback در لاگ؛ `RequestDataTooBig`). nginx هنوز تا ۱۰۰ مگابایت بافر میکند (بند ۱۸) | `config/tests_request_size.py` |
|
||||||
| کتابخانهها بدونِ آسیبپذیریِ شناختهشده (`urllib3>=2.8.0`، `python-dotenv 1.2.2`) | پایگاهِ OSV روی `pip freeze` |
|
| کتابخانهها بدونِ آسیبپذیریِ شناختهشده (`urllib3>=2.8.0`، `python-dotenv 1.2.2`) | پایگاهِ OSV روی `pip freeze` |
|
||||||
| actionهای لغو/بازپرداختِ سفارش، علامتِ «بررسیشده» و لینکِ بازار اجازهی تغییر میخواهند؛ کارمندِ فقطخواندنی (Supervisor) اجرایشان نمیکند | `payment/tests_security_review.py`، `content/tests_security_review.py` |
|
| actionهای لغو/بازپرداختِ سفارش، علامتِ «بررسیشده» و لینکِ بازار اجازهی تغییر میخواهند؛ کارمندِ فقطخواندنی (Supervisor) اجرایشان نمیکند | `payment/tests_security_review.py`، `content/tests_security_review.py` |
|
||||||
| ورودِ فایلِ سوال/برنامهی درسی اجازهی افزودن (و برای جایگزینی، حذف) میخواهد؛ مرورگرِ محتوا اجازهی دیدنِ سوال | `content/tests_security_review.py` |
|
| ورودِ فایلِ سوال/برنامهی درسی اجازهی افزودن (و برای جایگزینی، حذف) میخواهد؛ مرورگرِ محتوا اجازهی دیدنِ سوال | `content/tests_security_review.py` |
|
||||||
|
|||||||
Reference in New Issue
Block a user