bottleneckcrm/backend/webforms/throttles.py
Ashwin 14cd99fb3c feat: Implement per-client and global rate limits for magic-link and portal sign-in requests
- Added `FirstRefusalThrottleMixin` to various views to prevent a single client from consuming shared throttle limits.
- Introduced new throttles: `MagicLinkIPThrottle`, `MagicLinkGlobalThrottle`, `PortalLoginIPThrottle`, and `PortalLoginOrgThrottle`.
- Updated environment variables in `.env.example` to include new throttle settings.
- Enhanced tests to verify that refused clients do not affect global limits.
- Updated documentation to reflect changes in authentication and throttling behavior.
2026-10-02 12:23:22 +05:30

43 lines
1.6 KiB
Python

"""Rate limits for the public web form submit endpoint.
Two layers, because neither is sufficient alone:
- `WebFormIPThrottle` buckets on `common.request_meta.client_ip`, which
believes only the `X-Forwarded-For` entries our own proxies appended, so
rotating the header does not buy a fresh bucket. It stops one visitor or one
bot from flooding.
- `WebFormGlobalThrottle` caps submissions per form across all clients, the
backstop for a sender spread across many real addresses. The view checks
them in that order and stops at the first refusal
(`common.throttles.FirstRefusalThrottleMixin`), so a client the per-IP limit
refused cannot fill the form's daily cap alone.
Both need a shared cache to mean anything across workers. `crm/settings.py`
configures one from `CACHE_URL` and says what happens when it is not set.
"""
from rest_framework.throttling import SimpleRateThrottle
from common.request_meta import client_ip
class WebFormIPThrottle(SimpleRateThrottle):
scope = "webform_submit_ip"
def get_cache_key(self, request, view):
# Bucketed per form as well as per IP, so a visitor who submits one
# org's form is not locked out of a different org's form.
return self.cache_format % {
"scope": self.scope,
"ident": f"{view.kwargs.get('form_id')}:{client_ip(request) or 'unknown'}",
}
class WebFormGlobalThrottle(SimpleRateThrottle):
scope = "webform_submit_global"
def get_cache_key(self, request, view):
return self.cache_format % {
"scope": self.scope,
"ident": str(view.kwargs.get("form_id")),
}