From 1d7eb829e25e4e27b7436af53062c2d14a0c1d50 Mon Sep 17 00:00:00 2001 From: Zareian Date: Wed, 7 Oct 2026 14:48:03 +0330 Subject: [PATCH] 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 --- config/exceptions.py | 15 ++++++++++++++- config/tests_request_size.py | 31 ++++++++++++++++++++++++++++++ docs/security-before-production.md | 1 + 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 config/tests_request_size.py diff --git a/config/exceptions.py b/config/exceptions.py index b5911bb..e38e0b2 100644 --- a/config/exceptions.py +++ b/config/exceptions.py @@ -7,6 +7,7 @@ JSON. این هندلر اون حالت رو با traceback کامل لاگ می """ import logging +from django.core.exceptions import RequestDataTooBig, SuspiciousOperation from rest_framework.response import Response from rest_framework.views import exception_handler as drf_exception_handler @@ -19,11 +20,23 @@ def custom_exception_handler(exc, context): return response 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_id = user.id if getattr(user, 'is_authenticated', False) else None logger.exception( 'unhandled_exception path=%s user_id=%s', - getattr(request, 'path', ''), user_id, + path, user_id, ) return Response( {'detail': 'خطای غیرمنتظره‌ای رخ داد؛ لطفاً بعداً دوباره تلاش کنید.'}, diff --git a/config/tests_request_size.py b/config/tests_request_size.py new file mode 100644 index 0000000..86901e4 --- /dev/null +++ b/config/tests_request_size.py @@ -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()) diff --git a/docs/security-before-production.md b/docs/security-before-production.md index 06ac333..c0014be 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -271,6 +271,7 @@ | پایانِ درس و پایانِ آزمونِ جزیره جوابِ ثبت‌شده به همه‌ی سوال‌ها می‌خواهند (۴۰۹ `answers_missing`)، روی جزیره‌ی قفل ۴۰۳ `node_locked` می‌دهند (همان قاعده‌ی نقشه)، جزیره یا درسِ بی‌سوال چیزی برای جواب‌دادن ندارد و اگر باز است بی‌امتیاز رد می‌شود (قفل بودنش هنوز چک می‌شود)، و تعدادِ سوالِ آزمون را کلاینت نمی‌تواند کمتر از آزمونِ استانداردِ ۴ تایی بگوید (ستاره‌ی دروغین) | `content/tests_finish_requires_answers.py`، `content/tests_node_completion.py` | | کلیدِ امضای توکن تصادفی و فقط روی سرور است (`.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ِ محلی که وسطِ کار می‌میرد) | +| بدنهٔ درخواست بزرگ‌تر از ≈ ۲٫۵ مگابایت `413` می‌گیرد و ERRORِ لاگ نمی‌سازد (پیش‌تر `500` + traceback در لاگ؛ `RequestDataTooBig`). nginx هنوز تا ۱۰۰ مگابایت بافر می‌کند (بند ۱۸) | `config/tests_request_size.py` | | کتابخانه‌ها بدونِ آسیب‌پذیریِ شناخته‌شده (`urllib3>=2.8.0`، `python-dotenv 1.2.2`) | پایگاهِ OSV روی `pip freeze` | | actionهای لغو/بازپرداختِ سفارش، علامتِ «بررسی‌شده» و لینکِ بازار اجازه‌ی تغییر می‌خواهند؛ کارمندِ فقط‌خواندنی (Supervisor) اجرایشان نمی‌کند | `payment/tests_security_review.py`، `content/tests_security_review.py` | | ورودِ فایلِ سوال/برنامه‌ی درسی اجازه‌ی افزودن (و برای جایگزینی، حذف) می‌خواهد؛ مرورگرِ محتوا اجازه‌ی دیدنِ سوال | `content/tests_security_review.py` |