From bdc916fac02111358e78b7681c1481e0fba24c51 Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Tue, 5 May 2026 22:45:17 +0800 Subject: [PATCH 1/6] implement API rate limiter and throttling --- queueless_backend/queue_tracker/throttles.py | 9 +++++ queueless_backend/queue_tracker/views.py | 5 +++ .../queueless_backend/settings.py | 12 ++++++ scratch/test_throttling.py | 37 +++++++++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 queueless_backend/queue_tracker/throttles.py create mode 100644 scratch/test_throttling.py diff --git a/queueless_backend/queue_tracker/throttles.py b/queueless_backend/queue_tracker/throttles.py new file mode 100644 index 0000000..6fb1d98 --- /dev/null +++ b/queueless_backend/queue_tracker/throttles.py @@ -0,0 +1,9 @@ +from rest_framework.throttling import AnonRateThrottle + + +class JoinQueueRateThrottle(AnonRateThrottle): + scope = "join" + + +class BurstRateThrottle(AnonRateThrottle): + scope = "burst" diff --git a/queueless_backend/queue_tracker/views.py b/queueless_backend/queue_tracker/views.py index 7bf640b..03ad0d4 100644 --- a/queueless_backend/queue_tracker/views.py +++ b/queueless_backend/queue_tracker/views.py @@ -22,10 +22,12 @@ maybe_auto_tick_institution, simulate_queue_tick_for_institution, ) +from .throttles import BurstRateThrottle, JoinQueueRateThrottle class QueueJoinView(APIView): permission_classes = [permissions.AllowAny] + throttle_classes = [JoinQueueRateThrottle] def post(self, request): serializer = QueueJoinSerializer(data=request.data) @@ -129,6 +131,7 @@ def post(self, request): class QueueEntryStatusView(APIView): permission_classes = [permissions.AllowAny] + throttle_classes = [BurstRateThrottle] def get(self, request, session_id): try: @@ -162,6 +165,7 @@ def get(self, request, session_id): class QueueEntryCheckInView(APIView): permission_classes = [permissions.AllowAny] + throttle_classes = [BurstRateThrottle] def patch(self, request, session_id): entry, error = check_in_serving_entry(session_id) @@ -182,6 +186,7 @@ def patch(self, request, session_id): class QueueEntryCancelView(APIView): permission_classes = [permissions.AllowAny] + throttle_classes = [BurstRateThrottle] def post(self, request, session_id): entry, error = cancel_queue_entry(session_id) diff --git a/queueless_backend/queueless_backend/settings.py b/queueless_backend/queueless_backend/settings.py index 13f41fa..ba10052 100644 --- a/queueless_backend/queueless_backend/settings.py +++ b/queueless_backend/queueless_backend/settings.py @@ -179,6 +179,18 @@ def env_bool(name: str, default: bool = False) -> bool: }, } DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +REST_FRAMEWORK = { + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "anon": "100/day", + "user": "1000/day", + "burst": "30/minute", + "join": "5/minute", + }, +} # CORS configuration from environment variables. CORS_ALLOW_ALL_ORIGINS = env_bool("CORS_ALLOW_ALL_ORIGINS", default=False) diff --git a/scratch/test_throttling.py b/scratch/test_throttling.py new file mode 100644 index 0000000..bcf99d6 --- /dev/null +++ b/scratch/test_throttling.py @@ -0,0 +1,37 @@ +import sys +import os +import django +from rest_framework.test import APIClient +from rest_framework import status + +# Setup Django +sys.path.append(os.path.join(os.getcwd(), 'queueless_backend')) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'queueless_backend.settings') +django.setup() + +from mock_api.models import Institution + +def test_join_throttle(): + client = APIClient() + institution = Institution.objects.first() + if not institution: + print("No institution found to test.") + return + + print(f"Testing Join Queue throttle for institution {institution.id}...") + + # We set 5/min in settings + for i in range(1, 8): + response = client.post('/api/queue/join/', { + "institution_id": institution.id, + "queue_number": 1000 + i + }) + print(f"Request {i}: Status {response.status_code}") + if response.status_code == 429: + print(">>> SUCCESS: Rate limit triggered at request", i) + return + + print(">>> FAILURE: Rate limit NOT triggered after 7 requests.") + +if __name__ == "__main__": + test_join_throttle() From b38b38b823d7638c73a041f86e7ff1d595999810 Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Tue, 5 May 2026 22:54:30 +0800 Subject: [PATCH 2/6] docs: readme update --- README.md | 2 +- scratch/test_throttling.py | 37 ------------------------------------- 2 files changed, 1 insertion(+), 38 deletions(-) delete mode 100644 scratch/test_throttling.py diff --git a/README.md b/README.md index b51ef84..d4b177b 100644 --- a/README.md +++ b/README.md @@ -202,4 +202,4 @@ QueueLess-Backend/ - If you add new apps (like `queue_tracker`), register them in `INSTALLED_APPS` in `queueless_backend/settings.py`. - Configure `CORS_ALLOWED_ORIGINS` when connecting a frontend. -- Add Channels routing and ASGI configuration when you introduce WebSocket consumers. +- Add Channels routing and ASGI configuration when you introduce WebSocket consumers. \ No newline at end of file diff --git a/scratch/test_throttling.py b/scratch/test_throttling.py deleted file mode 100644 index bcf99d6..0000000 --- a/scratch/test_throttling.py +++ /dev/null @@ -1,37 +0,0 @@ -import sys -import os -import django -from rest_framework.test import APIClient -from rest_framework import status - -# Setup Django -sys.path.append(os.path.join(os.getcwd(), 'queueless_backend')) -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'queueless_backend.settings') -django.setup() - -from mock_api.models import Institution - -def test_join_throttle(): - client = APIClient() - institution = Institution.objects.first() - if not institution: - print("No institution found to test.") - return - - print(f"Testing Join Queue throttle for institution {institution.id}...") - - # We set 5/min in settings - for i in range(1, 8): - response = client.post('/api/queue/join/', { - "institution_id": institution.id, - "queue_number": 1000 + i - }) - print(f"Request {i}: Status {response.status_code}") - if response.status_code == 429: - print(">>> SUCCESS: Rate limit triggered at request", i) - return - - print(">>> FAILURE: Rate limit NOT triggered after 7 requests.") - -if __name__ == "__main__": - test_join_throttle() From b8200a077f8ff296b43e0d5b4b55132a08241df6 Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Tue, 5 May 2026 22:57:02 +0800 Subject: [PATCH 3/6] docs: readme update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d4b177b..b51ef84 100644 --- a/README.md +++ b/README.md @@ -202,4 +202,4 @@ QueueLess-Backend/ - If you add new apps (like `queue_tracker`), register them in `INSTALLED_APPS` in `queueless_backend/settings.py`. - Configure `CORS_ALLOWED_ORIGINS` when connecting a frontend. -- Add Channels routing and ASGI configuration when you introduce WebSocket consumers. \ No newline at end of file +- Add Channels routing and ASGI configuration when you introduce WebSocket consumers. From a39b37372a3e7f128ec880bd6405482800642051 Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Tue, 5 May 2026 23:14:56 +0800 Subject: [PATCH 4/6] fix: throttling blind spot, hardened for real-world traffi, and automated verification --- queueless_backend/queue_tracker/tests.py | 44 +++++++++++++++++++ queueless_backend/queue_tracker/views.py | 9 ++-- .../queueless_backend/settings.py | 10 +++-- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/queueless_backend/queue_tracker/tests.py b/queueless_backend/queue_tracker/tests.py index 9043c39..82c7081 100644 --- a/queueless_backend/queue_tracker/tests.py +++ b/queueless_backend/queue_tracker/tests.py @@ -380,3 +380,47 @@ def test_rejoin_after_cancel(self): ) self.assertEqual(response_join_success.status_code, status.HTTP_201_CREATED) self.assertEqual(response_join_success.data["queue_number"], 20) + + +class QueueThrottlingTests(TestCase): + def setUp(self): + self.client = APIClient() + self.institution = Institution.objects.create( + name="Throttled Office", + institution_type=Institution.InstitutionType.GOVERNMENT, + status=Institution.Status.OPEN, + is_active=True, + ) + cache.clear() + + def test_join_queue_throttling(self): + # Join limit is 5/minute in settings.py + for i in range(1, 6): + response = self.client.post( + "/api/queue/join/", + {"institution_id": self.institution.id, "queue_number": 100 + i}, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # 6th request should be throttled + response = self.client.post( + "/api/queue/join/", + {"institution_id": self.institution.id, "queue_number": 106}, + ) + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + + def test_status_polling_throttling(self): + entry = QueueEntry.objects.create( + institution=self.institution, + queue_number=10, + current_serving_number=5, + status=QueueEntryStatus.WAITING, + ) + # Burst limit is 60/minute (adjusted in settings) + for _ in range(60): + response = self.client.get(f"/api/queue/entries/{entry.session_id}/status/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # 61st request should be throttled + response = self.client.get(f"/api/queue/entries/{entry.session_id}/status/") + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) diff --git a/queueless_backend/queue_tracker/views.py b/queueless_backend/queue_tracker/views.py index 03ad0d4..841086a 100644 --- a/queueless_backend/queue_tracker/views.py +++ b/queueless_backend/queue_tracker/views.py @@ -22,12 +22,11 @@ maybe_auto_tick_institution, simulate_queue_tick_for_institution, ) -from .throttles import BurstRateThrottle, JoinQueueRateThrottle class QueueJoinView(APIView): permission_classes = [permissions.AllowAny] - throttle_classes = [JoinQueueRateThrottle] + throttle_scope = "join" def post(self, request): serializer = QueueJoinSerializer(data=request.data) @@ -131,7 +130,7 @@ def post(self, request): class QueueEntryStatusView(APIView): permission_classes = [permissions.AllowAny] - throttle_classes = [BurstRateThrottle] + throttle_scope = "burst" def get(self, request, session_id): try: @@ -165,7 +164,7 @@ def get(self, request, session_id): class QueueEntryCheckInView(APIView): permission_classes = [permissions.AllowAny] - throttle_classes = [BurstRateThrottle] + throttle_scope = "burst" def patch(self, request, session_id): entry, error = check_in_serving_entry(session_id) @@ -186,7 +185,7 @@ def patch(self, request, session_id): class QueueEntryCancelView(APIView): permission_classes = [permissions.AllowAny] - throttle_classes = [BurstRateThrottle] + throttle_scope = "burst" def post(self, request, session_id): entry, error = cancel_queue_entry(session_id) diff --git a/queueless_backend/queueless_backend/settings.py b/queueless_backend/queueless_backend/settings.py index ba10052..bdd1ab7 100644 --- a/queueless_backend/queueless_backend/settings.py +++ b/queueless_backend/queueless_backend/settings.py @@ -183,13 +183,15 @@ def env_bool(name: str, default: bool = False) -> bool: "DEFAULT_THROTTLE_CLASSES": [ "rest_framework.throttling.AnonRateThrottle", "rest_framework.throttling.UserRateThrottle", + "rest_framework.throttling.ScopedRateThrottle", ], "DEFAULT_THROTTLE_RATES": { - "anon": "100/day", - "user": "1000/day", - "burst": "30/minute", - "join": "5/minute", + "anon": os.getenv("DRF_THROTTLE_RATE_ANON", "20000/day"), + "user": os.getenv("DRF_THROTTLE_RATE_USER", "100000/day"), + "burst": os.getenv("DRF_THROTTLE_RATE_BURST", "60/minute"), + "join": os.getenv("DRF_THROTTLE_RATE_JOIN", "5/minute"), }, + "NUM_PROXIES": int(os.getenv("DRF_NUM_PROXIES", "1")), } # CORS configuration from environment variables. From d7ebe458140fac48a21ecc26222559d5cd9af7d2 Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Tue, 5 May 2026 23:34:46 +0800 Subject: [PATCH 5/6] fix: enhanced security logic and cleanup dead code --- queueless_backend/queue_tracker/tests.py | 34 +++++++++++++++---- queueless_backend/queue_tracker/throttles.py | 9 ----- .../queueless_backend/settings.py | 8 ++++- 3 files changed, 34 insertions(+), 17 deletions(-) delete mode 100644 queueless_backend/queue_tracker/throttles.py diff --git a/queueless_backend/queue_tracker/tests.py b/queueless_backend/queue_tracker/tests.py index 82c7081..f579e3b 100644 --- a/queueless_backend/queue_tracker/tests.py +++ b/queueless_backend/queue_tracker/tests.py @@ -393,34 +393,54 @@ def setUp(self): ) cache.clear() + def _get_rate_limit(self, scope): + """Helper to parse DRF throttle rate string (e.g. '5/minute') into int.""" + from django.conf import settings + + rates = settings.REST_FRAMEWORK.get("DEFAULT_THROTTLE_RATES", {}) + rate_str = rates.get(scope, "0/day") + try: + return int(rate_str.split("/")[0]) + except (ValueError, IndexError): + return 0 + def test_join_queue_throttling(self): - # Join limit is 5/minute in settings.py - for i in range(1, 6): + limit = self._get_rate_limit("join") + if limit <= 0: + self.skipTest("Join throttle limit not configured") + + # Send 'limit' number of successful requests + for i in range(1, limit + 1): response = self.client.post( "/api/queue/join/", {"institution_id": self.institution.id, "queue_number": 100 + i}, ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - # 6th request should be throttled + # The next request should be throttled response = self.client.post( "/api/queue/join/", - {"institution_id": self.institution.id, "queue_number": 106}, + {"institution_id": self.institution.id, "queue_number": 999}, ) self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) def test_status_polling_throttling(self): + limit = self._get_rate_limit("burst") + if limit <= 0: + self.skipTest("Burst throttle limit not configured") + entry = QueueEntry.objects.create( institution=self.institution, queue_number=10, current_serving_number=5, status=QueueEntryStatus.WAITING, ) - # Burst limit is 60/minute (adjusted in settings) - for _ in range(60): + + # Send 'limit' number of successful requests + for _ in range(limit): response = self.client.get(f"/api/queue/entries/{entry.session_id}/status/") self.assertEqual(response.status_code, status.HTTP_200_OK) - # 61st request should be throttled + # The next request should be throttled response = self.client.get(f"/api/queue/entries/{entry.session_id}/status/") self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) diff --git a/queueless_backend/queue_tracker/throttles.py b/queueless_backend/queue_tracker/throttles.py deleted file mode 100644 index 6fb1d98..0000000 --- a/queueless_backend/queue_tracker/throttles.py +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework.throttling import AnonRateThrottle - - -class JoinQueueRateThrottle(AnonRateThrottle): - scope = "join" - - -class BurstRateThrottle(AnonRateThrottle): - scope = "burst" diff --git a/queueless_backend/queueless_backend/settings.py b/queueless_backend/queueless_backend/settings.py index bdd1ab7..12c0d21 100644 --- a/queueless_backend/queueless_backend/settings.py +++ b/queueless_backend/queueless_backend/settings.py @@ -179,6 +179,12 @@ def env_bool(name: str, default: bool = False) -> bool: }, } DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +try: + _drf_num_proxies = os.getenv("DRF_NUM_PROXIES") + DRF_NUM_PROXIES = int(_drf_num_proxies) if _drf_num_proxies is not None else None +except ValueError as exc: + raise ImproperlyConfigured("DRF_NUM_PROXIES must be an integer.") from exc + REST_FRAMEWORK = { "DEFAULT_THROTTLE_CLASSES": [ "rest_framework.throttling.AnonRateThrottle", @@ -191,7 +197,7 @@ def env_bool(name: str, default: bool = False) -> bool: "burst": os.getenv("DRF_THROTTLE_RATE_BURST", "60/minute"), "join": os.getenv("DRF_THROTTLE_RATE_JOIN", "5/minute"), }, - "NUM_PROXIES": int(os.getenv("DRF_NUM_PROXIES", "1")), + "NUM_PROXIES": DRF_NUM_PROXIES, } # CORS configuration from environment variables. From 1703e60d79f3ed22791257d18c810d9aecd6ebc7 Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Tue, 5 May 2026 23:47:58 +0800 Subject: [PATCH 6/6] fix: stricter security validation, and safe & isolated testing --- queueless_backend/queue_tracker/tests.py | 82 +++++++++---------- .../queueless_backend/settings.py | 2 + 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/queueless_backend/queue_tracker/tests.py b/queueless_backend/queue_tracker/tests.py index f579e3b..0aa64cd 100644 --- a/queueless_backend/queue_tracker/tests.py +++ b/queueless_backend/queue_tracker/tests.py @@ -1,7 +1,8 @@ from datetime import timedelta +from unittest.mock import patch from django.core.cache import cache -from django.test import SimpleTestCase, TestCase +from django.test import SimpleTestCase, TestCase, override_settings from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient @@ -382,6 +383,18 @@ def test_rejoin_after_cancel(self): self.assertEqual(response_join_success.data["queue_number"], 20) +@override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "throttling-test-cache", + } + }, + REST_FRAMEWORK={ + "DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.ScopedRateThrottle"], + "DEFAULT_THROTTLE_RATES": {"join": "1/minute", "burst": "1/minute"}, + }, +) class QueueThrottlingTests(TestCase): def setUp(self): self.client = APIClient() @@ -393,54 +406,41 @@ def setUp(self): ) cache.clear() - def _get_rate_limit(self, scope): - """Helper to parse DRF throttle rate string (e.g. '5/minute') into int.""" - from django.conf import settings - - rates = settings.REST_FRAMEWORK.get("DEFAULT_THROTTLE_RATES", {}) - rate_str = rates.get(scope, "0/day") - try: - return int(rate_str.split("/")[0]) - except (ValueError, IndexError): - return 0 - def test_join_queue_throttling(self): - limit = self._get_rate_limit("join") - if limit <= 0: - self.skipTest("Join throttle limit not configured") - - # Send 'limit' number of successful requests - for i in range(1, limit + 1): + # We patch ScopedRateThrottle.THROTTLE_RATES because DRF loads it once + with patch( + "rest_framework.throttling.ScopedRateThrottle.THROTTLE_RATES", + {"join": "1/minute", "burst": "1/minute"}, + ): + # Join limit is pinned to 1/minute response = self.client.post( "/api/queue/join/", - {"institution_id": self.institution.id, "queue_number": 100 + i}, + {"institution_id": self.institution.id, "queue_number": 101}, ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - # The next request should be throttled - response = self.client.post( - "/api/queue/join/", - {"institution_id": self.institution.id, "queue_number": 999}, - ) - self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + # 2nd request should be throttled + response = self.client.post( + "/api/queue/join/", + {"institution_id": self.institution.id, "queue_number": 102}, + ) + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) def test_status_polling_throttling(self): - limit = self._get_rate_limit("burst") - if limit <= 0: - self.skipTest("Burst throttle limit not configured") - - entry = QueueEntry.objects.create( - institution=self.institution, - queue_number=10, - current_serving_number=5, - status=QueueEntryStatus.WAITING, - ) - - # Send 'limit' number of successful requests - for _ in range(limit): + with patch( + "rest_framework.throttling.ScopedRateThrottle.THROTTLE_RATES", + {"join": "1/minute", "burst": "1/minute"}, + ): + entry = QueueEntry.objects.create( + institution=self.institution, + queue_number=10, + current_serving_number=5, + status=QueueEntryStatus.WAITING, + ) + # Burst limit is pinned to 1/minute response = self.client.get(f"/api/queue/entries/{entry.session_id}/status/") self.assertEqual(response.status_code, status.HTTP_200_OK) - # The next request should be throttled - response = self.client.get(f"/api/queue/entries/{entry.session_id}/status/") - self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + # 2nd request should be throttled + response = self.client.get(f"/api/queue/entries/{entry.session_id}/status/") + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) diff --git a/queueless_backend/queueless_backend/settings.py b/queueless_backend/queueless_backend/settings.py index 12c0d21..c7c32d3 100644 --- a/queueless_backend/queueless_backend/settings.py +++ b/queueless_backend/queueless_backend/settings.py @@ -182,6 +182,8 @@ def env_bool(name: str, default: bool = False) -> bool: try: _drf_num_proxies = os.getenv("DRF_NUM_PROXIES") DRF_NUM_PROXIES = int(_drf_num_proxies) if _drf_num_proxies is not None else None + if DRF_NUM_PROXIES is not None and DRF_NUM_PROXIES < 0: + raise ImproperlyConfigured("DRF_NUM_PROXIES must be a non-negative integer.") except ValueError as exc: raise ImproperlyConfigured("DRF_NUM_PROXIES must be an integer.") from exc