Skip to content

Commit b9c8246

Browse files
committed
Add configurable API rate limiting
1 parent ecfcf63 commit b9c8246

11 files changed

Lines changed: 268 additions & 27 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
44
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55

6+
## v1.1.0develop pathocore-api
7+
8+
### `Changed`
9+
10+
### `Added`
11+
12+
- [#14](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/14) Add configurable rate limits to protect API endpoints.
13+
14+
### `Fixed`
15+
16+
### `Dependencies`
17+
18+
### `Deprecated`
19+
620
## v1.0.0 pathocore-api - "Initial Release" 2026/06/01
721

822
### `Changed`

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,13 @@ Django superuser credentials:
253253
admin / admin_pass
254254
```
255255

256+
### API rate limiting
257+
258+
PathoCore API includes configurable server-side rate limits with
259+
`django-ratelimit`. Thresholds are controlled through
260+
`PATHOCORE_RATELIMIT_*` environment variables and requests over the limit return
261+
`429 Too Many Requests`.
262+
256263
## Access Request Workflow
257264

258265
PathoCore keeps pending registration and approval state in its own database.

conf/docker_production_settings.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ DNS_URL='pathocore-api.example.org'
2020
### Keycloak authentication settings
2121
PATHOCORE_ENABLE_LEGACY_BASIC_AUTH="false"
2222
PATHOCORE_ENABLE_PUBLIC_READ_ENDPOINTS="true"
23+
PATHOCORE_RATELIMIT_ENABLED="true"
24+
PATHOCORE_RATELIMIT_PUBLIC_RATE="100/m"
25+
PATHOCORE_RATELIMIT_AUTHENTICATED_RATE="300/m"
26+
PATHOCORE_RATELIMIT_EXPENSIVE_RATE="60/m"
27+
PATHOCORE_RATELIMIT_WRITE_RATE="20/m"
28+
PATHOCORE_RATELIMIT_IP_META_KEY=""
29+
PATHOCORE_RATELIMIT_CACHE="default"
2330
PATHOCORE_CREATE_DEFAULT_SUPERUSER="false"
2431
DJANGO_SUPERUSER_USERNAME=""
2532
DJANGO_SUPERUSER_EMAIL=""

conf/docker_test_settings.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ DNS_URL='localhost'
2020
### Keycloak authentication settings
2121
PATHOCORE_ENABLE_LEGACY_BASIC_AUTH="true"
2222
PATHOCORE_ENABLE_PUBLIC_READ_ENDPOINTS="true"
23+
PATHOCORE_RATELIMIT_ENABLED="true"
24+
PATHOCORE_RATELIMIT_PUBLIC_RATE="100/m"
25+
PATHOCORE_RATELIMIT_AUTHENTICATED_RATE="300/m"
26+
PATHOCORE_RATELIMIT_EXPENSIVE_RATE="60/m"
27+
PATHOCORE_RATELIMIT_WRITE_RATE="20/m"
28+
PATHOCORE_RATELIMIT_IP_META_KEY=""
29+
PATHOCORE_RATELIMIT_CACHE="default"
2330
PATHOCORE_CREATE_DEFAULT_SUPERUSER="true"
2431
DJANGO_SUPERUSER_USERNAME="admin"
2532
DJANGO_SUPERUSER_EMAIL="admin@example.org"

conf/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ mysqlclient==2.2.4
55
mod_wsgi==5.0.0
66
gunicorn==21.2.0
77
PyJWT[crypto]==2.9.0
8+
django-ratelimit==4.1.0

conf/template_settings.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ def _int_env(name, default):
4646
return 25
4747

4848

49+
def _bool_env(name, default=False):
50+
raw_value = os.environ.get(name)
51+
if raw_value is None:
52+
return bool(default)
53+
return raw_value.strip().lower() in ("1", "true", "yes", "on")
54+
55+
56+
def _rate_env(name, default):
57+
return os.environ.get(name, default).strip()
58+
59+
4960
# Quick-start development settings - unsuitable for production
5061
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
5162

@@ -79,6 +90,7 @@ def _int_env(name, default):
7990
"django.contrib.auth.middleware.AuthenticationMiddleware",
8091
"django.contrib.messages.middleware.MessageMiddleware",
8192
"django.middleware.clickjacking.XFrameOptionsMiddleware",
93+
"core.api.ratelimit.PathoCoreRatelimitMiddleware",
8294
]
8395

8496
ROOT_URLCONF = "pathocore_api.urls"
@@ -155,6 +167,20 @@ def _int_env(name, default):
155167
"PATHOCORE_ENABLE_PUBLIC_READ_ENDPOINTS", "true"
156168
).lower() in ("1", "true", "yes", "on")
157169

170+
# API rate limiting. Values use django-ratelimit syntax, for example:
171+
# "100/m", "1000/h", or "0"/"off" to disable one category.
172+
PATHOCORE_RATELIMIT_ENABLED = _bool_env("PATHOCORE_RATELIMIT_ENABLED", True)
173+
PATHOCORE_RATELIMIT_RATES = {
174+
"public": _rate_env("PATHOCORE_RATELIMIT_PUBLIC_RATE", "100/m"),
175+
"authenticated": _rate_env("PATHOCORE_RATELIMIT_AUTHENTICATED_RATE", "300/m"),
176+
"expensive": _rate_env("PATHOCORE_RATELIMIT_EXPENSIVE_RATE", "60/m"),
177+
"write": _rate_env("PATHOCORE_RATELIMIT_WRITE_RATE", "20/m"),
178+
}
179+
PATHOCORE_RATELIMIT_IP_META_KEY = os.environ.get(
180+
"PATHOCORE_RATELIMIT_IP_META_KEY", ""
181+
).strip()
182+
RATELIMIT_USE_CACHE = os.environ.get("PATHOCORE_RATELIMIT_CACHE", "default").strip()
183+
158184
# Keycloak setup
159185
KEYCLOAK_ISSUER = os.environ.get("KEYCLOAK_ISSUER", "").strip()
160186
KEYCLOAK_CLIENT_ID = os.environ.get("KEYCLOAK_CLIENT_ID", "").strip()

core/api/ratelimit.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import base64
2+
import hashlib
3+
import json
4+
5+
from django.conf import settings
6+
from django.http import JsonResponse
7+
from django_ratelimit.decorators import ratelimit
8+
from django_ratelimit.exceptions import Ratelimited
9+
10+
DISABLED_RATE_VALUES = {"", "0", "off", "none", "false"}
11+
12+
13+
class PathoCoreRatelimitMiddleware:
14+
def __init__(self, get_response):
15+
self.get_response = get_response
16+
17+
def __call__(self, request):
18+
return self.get_response(request)
19+
20+
def process_exception(self, request, exception):
21+
if not isinstance(exception, Ratelimited):
22+
return None
23+
return JsonResponse(
24+
{
25+
"detail": "Rate limit exceeded.",
26+
"error": (
27+
"Too many API requests. Wait before retrying or contact "
28+
"the PathoCore administrators if this limit is too strict."
29+
),
30+
},
31+
status=429,
32+
)
33+
34+
35+
def apply_api_ratelimit(view, *, category):
36+
if not getattr(settings, "PATHOCORE_RATELIMIT_ENABLED", True):
37+
return view
38+
39+
rate = _rate_for_category(category)
40+
if rate is None:
41+
return view
42+
43+
return ratelimit(
44+
group=f"pathocore-api:{category}",
45+
key=ratelimit_identity_key,
46+
rate=rate,
47+
block=True,
48+
)(view)
49+
50+
51+
def ratelimit_identity_key(group, request):
52+
authorization = request.META.get("HTTP_AUTHORIZATION", "").strip()
53+
if authorization:
54+
return _authorization_key(authorization)
55+
return f"ip:{_client_ip(request)}"
56+
57+
58+
def _rate_for_category(category):
59+
rates = getattr(settings, "PATHOCORE_RATELIMIT_RATES", {})
60+
rate = str(rates.get(category, "")).strip().lower()
61+
if rate in DISABLED_RATE_VALUES:
62+
return None
63+
return rate
64+
65+
66+
def _authorization_key(authorization):
67+
scheme, _, credentials = authorization.partition(" ")
68+
scheme = scheme.strip().lower()
69+
credentials = credentials.strip()
70+
71+
if scheme == "bearer":
72+
subject = _jwt_subject(credentials)
73+
if subject:
74+
return f"bearer-sub:{subject}"
75+
76+
if scheme == "basic":
77+
username = _basic_username(credentials)
78+
if username:
79+
return f"basic-user:{username}"
80+
81+
digest = hashlib.sha256(authorization.encode("utf-8")).hexdigest()
82+
return f"auth-hash:{digest}"
83+
84+
85+
def _jwt_subject(token):
86+
parts = token.split(".")
87+
if len(parts) < 2:
88+
return ""
89+
payload = _urlsafe_b64decode(parts[1])
90+
if payload is None:
91+
return ""
92+
try:
93+
data = json.loads(payload.decode("utf-8"))
94+
except (json.JSONDecodeError, UnicodeDecodeError):
95+
return ""
96+
return str(data.get("sub") or data.get("preferred_username") or "").strip()
97+
98+
99+
def _basic_username(credentials):
100+
decoded = _urlsafe_b64decode(credentials)
101+
if decoded is None:
102+
return ""
103+
try:
104+
raw_value = decoded.decode("utf-8")
105+
except UnicodeDecodeError:
106+
return ""
107+
username, _, _ = raw_value.partition(":")
108+
return username.strip()
109+
110+
111+
def _urlsafe_b64decode(value):
112+
padding = "=" * (-len(value) % 4)
113+
try:
114+
return base64.urlsafe_b64decode(value + padding)
115+
except (ValueError, TypeError):
116+
return None
117+
118+
119+
def _client_ip(request):
120+
meta_key = getattr(settings, "PATHOCORE_RATELIMIT_IP_META_KEY", "").strip()
121+
if meta_key:
122+
value = request.META.get(meta_key, "").strip()
123+
if value:
124+
return value.split(",", 1)[0].strip()
125+
return request.META.get("REMOTE_ADDR", "")

0 commit comments

Comments
 (0)