django-aegis 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 loowtide
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-aegis
3
+ Version: 0.0.1
4
+ Summary: IP based blocker with rate limiting
5
+ Keywords: aegis,blocker,ip,rate limiting,api
6
+ Author: loowtide
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Topic :: Software Development :: Libraries
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Dist: django>=6.0.5
18
+ Requires-Dist: django-stubs>=6.0.4
19
+ Requires-Dist: pytest-django>=4.12.0
20
+ Requires-Dist: python-dotenv>=1.2.2
21
+ Requires-Python: >=3.13
22
+ Project-URL: Homepage, https://github.com/loowtide/aegis
23
+ Description-Content-Type: text/markdown
24
+
25
+ ![Aegis](docs/aegis.svg)
26
+ # Aegis
27
+
28
+ Django middleware that blocks requests from IPs listed in a database-backed blocklist and optional cache-backed rate limiting. Returns styled 403/429 responses with a `Retry-After` header and tracks every blocked attempt.
29
+
30
+ ## Features
31
+
32
+ - **IP Blocking** — database-backed blocklist with per-entry expiry (or permanent when `expires_at=None`)
33
+ - **Rate Limiting** — cache-backed sliding-window rate limiter
34
+ - **Auto-Blocking** — rate limit offenders can be automatically added to the blocklist
35
+ - Atomic tally and `last_seen` tracking on each hit
36
+ - Standards-compliant `Retry-After` header
37
+ - Human-readable retry duration in rendered templates
38
+ - Skip paths config (e.g., exempt admin/static from rate limiting)
39
+
40
+ ## Installation
41
+
42
+ Add the middlewares to `MIDDLEWARE` in `settings.py`. `BlockedIPMiddleware` should come first so blocked requests short-circuit before auth, sessions, and views:
43
+
44
+ ```python
45
+ MIDDLEWARE = [
46
+ "django.middleware.security.SecurityMiddleware",
47
+ "aegis.middleware.BlockedIPMiddleware",
48
+ "aegis.middleware.RateLimitMiddleware",
49
+ # ...
50
+ ]
51
+ ```
52
+
53
+ Rate limiting requires a Django cache backend. Add one if you don't have one already:
54
+
55
+ ```python
56
+ CACHES = {
57
+ "default": {
58
+ "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
59
+ }
60
+ }
61
+ ```
62
+
63
+ Ensure `aegis` is in `INSTALLED_APPS`, then run migrations:
64
+
65
+ ```bash
66
+ python manage.py migrate aegis
67
+ ```
68
+
69
+ ## Requirements
70
+
71
+ | Component | Purpose |
72
+ | -------------------------------- | -------------------------------------------------------------------- |
73
+ | `aegis.models.BlockedIP` | Model with fields: `ip`, `expires_at`, `last_seen`, `tally` |
74
+ | `aegis.utils.get_client_ip` | Extracts the real client IP from the request |
75
+ | `aegis.middleware.BlockedIPMiddleware` | Returns 403 for blocked IPs |
76
+ | `aegis.middleware.RateLimitMiddleware` | Returns 429 for rate-limited IPs, with optional auto-block |
77
+ | `aegis.rate_limit` | Cache-backed rate limit engine |
78
+ | `aegis/templates/aegis/403.html` | Template for blocked requests; receives `retry_after_human` |
79
+ | `aegis/templates/aegis/429.html` | Template for rate-limited requests; receives `retry_after_human` |
80
+
81
+ ## How It Works
82
+
83
+ On every request, each middleware runs in order:
84
+
85
+ ### BlockedIPMiddleware
86
+
87
+ 1. Resolves the client IP via `get_client_ip(request)`.
88
+ 2. Looks up an active `BlockedIP` record (`expires_at > now` or `expires_at IS NULL` for permanent blocks).
89
+ 3. If none exists, the request proceeds normally.
90
+ 4. If one exists:
91
+ - Atomically increments `tally` and updates `last_seen` using `F()`.
92
+ - Computes seconds remaining until `expires_at` (or `None` for permanent blocks).
93
+ - Renders `aegis/403.html` with a humanized duration (or `"the foreseeable future"` for permanent blocks).
94
+ - Returns `403 Forbidden` with a `Retry-After` header (omitted for permanent blocks).
95
+
96
+ ### RateLimitMiddleware
97
+
98
+ 1. Checks if the request path should be rate limited (respects `AEGIS_RATE_LIMIT_SKIP_PATHS`).
99
+ 2. Resolves the client IP.
100
+ 3. Increments a cache-based counter for the current time window.
101
+ 4. If the count exceeds `AEGIS_RATE_LIMIT_REQUESTS`:
102
+ - Increments a violation counter.
103
+ - Optionally auto-blocks the IP via `BlockedIP` if violations reach `AEGIS_RATE_LIMIT_AUTO_BLOCK_THRESHOLD`.
104
+ - Returns `429 Too Many Requests` with a `Retry-After` header.
105
+ 5. If within limits, allows the request (at most one violation counted per time window).
106
+
107
+ ## Response Format
108
+
109
+ ### 403 Forbidden
110
+
111
+ - **Status:** `403 Forbidden`
112
+ - **Header:** `Retry-After: <seconds>` — minimum `1` (omitted for permanent blocks)
113
+ - **Body:** Rendered HTML from `aegis/403.html`
114
+
115
+ ### 429 Too Many Requests
116
+
117
+ - **Status:** `429 Too Many Requests`
118
+ - **Header:** `Retry-After: <seconds>`
119
+ - **Body:** Rendered HTML from `aegis/429.html`
120
+
121
+ ## Humanized Durations
122
+
123
+ The `_humanize` helper converts seconds into the largest unit that fits, with correct pluralization:
124
+
125
+ | Range | Output |
126
+ | ------- | -------------- |
127
+ | `< 60s` | `"42 seconds"` |
128
+ | `< 60m` | `"7 minutes"` |
129
+ | `< 24h` | `"3 hours"` |
130
+ | `≥ 24h` | `"2 days"` |
131
+
132
+ ## Configuration
133
+
134
+ All settings are optional with sensible defaults.
135
+
136
+ ### Rate Limiting
137
+
138
+ | Setting | Default | Description |
139
+ | ------------------------------------- | -------- | --------------------------------------------- |
140
+ | `AEGIS_RATE_LIMIT_ENABLED` | `True` | Toggle rate limiting on/off |
141
+ | `AEGIS_RATE_LIMIT_REQUESTS` | `100` | Max requests per window |
142
+ | `AEGIS_RATE_LIMIT_WINDOW` | `60` | Time window in seconds |
143
+ | `AEGIS_RATE_LIMIT_AUTO_BLOCK` | `True` | Auto-block IPs that exceed violation threshold |
144
+ | `AEGIS_RATE_LIMIT_AUTO_BLOCK_THRESHOLD` | `5` | Consecutive limited windows before auto-block |
145
+ | `AEGIS_RATE_LIMIT_BLOCK_DURATION` | `3600` | Block duration in seconds (default 1 hour) |
146
+ | `AEGIS_RATE_LIMIT_SKIP_PATHS` | `[]` | Path prefixes to exclude from rate limiting |
147
+
148
+ ## Adding a Block
149
+
150
+ Create a `BlockedIP` row anywhere in your code — a signal, management command, admin action, or rate-limit handler:
151
+
152
+ ```python
153
+ from datetime import timedelta
154
+ from django.utils import timezone
155
+ from aegis.models import BlockedIP
156
+
157
+ BlockedIP.objects.create(
158
+ ip="203.0.113.45",
159
+ expires_at=timezone.now() + timedelta(hours=1),
160
+ )
161
+ ```
162
+
163
+ For a permanent block, omit `expires_at` (set it to `None`):
164
+
165
+ ```python
166
+ BlockedIP.objects.create(ip="203.0.113.45", reason="permanent ban")
167
+ ```
168
+
169
+ The block takes effect on the offender's next request — no restart or cache invalidation needed.
170
+
171
+ ## Running Tests
172
+
173
+ ```bash
174
+ pytest
175
+ ```
176
+
177
+ Tests are in `aegis/tests/` and cover:
178
+
179
+ | File | What it tests |
180
+ | ----------------------- | ---------------------------------------------------- |
181
+ | `test_models.py` | `BlockedIP.is_active` for permanent, future, expired |
182
+ | `test_utils.py` | `get_client_ip` with direct and proxied requests |
183
+ | `test_middleware.py` | 403 responses, pass-through, tally tracking |
184
+ | `test_rate_limit.py` | Rate limit counting, window slots, violations (one per window), 429 responses, auto-block, skip paths, permanent blocks |
@@ -0,0 +1,160 @@
1
+ ![Aegis](docs/aegis.svg)
2
+ # Aegis
3
+
4
+ Django middleware that blocks requests from IPs listed in a database-backed blocklist and optional cache-backed rate limiting. Returns styled 403/429 responses with a `Retry-After` header and tracks every blocked attempt.
5
+
6
+ ## Features
7
+
8
+ - **IP Blocking** — database-backed blocklist with per-entry expiry (or permanent when `expires_at=None`)
9
+ - **Rate Limiting** — cache-backed sliding-window rate limiter
10
+ - **Auto-Blocking** — rate limit offenders can be automatically added to the blocklist
11
+ - Atomic tally and `last_seen` tracking on each hit
12
+ - Standards-compliant `Retry-After` header
13
+ - Human-readable retry duration in rendered templates
14
+ - Skip paths config (e.g., exempt admin/static from rate limiting)
15
+
16
+ ## Installation
17
+
18
+ Add the middlewares to `MIDDLEWARE` in `settings.py`. `BlockedIPMiddleware` should come first so blocked requests short-circuit before auth, sessions, and views:
19
+
20
+ ```python
21
+ MIDDLEWARE = [
22
+ "django.middleware.security.SecurityMiddleware",
23
+ "aegis.middleware.BlockedIPMiddleware",
24
+ "aegis.middleware.RateLimitMiddleware",
25
+ # ...
26
+ ]
27
+ ```
28
+
29
+ Rate limiting requires a Django cache backend. Add one if you don't have one already:
30
+
31
+ ```python
32
+ CACHES = {
33
+ "default": {
34
+ "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
35
+ }
36
+ }
37
+ ```
38
+
39
+ Ensure `aegis` is in `INSTALLED_APPS`, then run migrations:
40
+
41
+ ```bash
42
+ python manage.py migrate aegis
43
+ ```
44
+
45
+ ## Requirements
46
+
47
+ | Component | Purpose |
48
+ | -------------------------------- | -------------------------------------------------------------------- |
49
+ | `aegis.models.BlockedIP` | Model with fields: `ip`, `expires_at`, `last_seen`, `tally` |
50
+ | `aegis.utils.get_client_ip` | Extracts the real client IP from the request |
51
+ | `aegis.middleware.BlockedIPMiddleware` | Returns 403 for blocked IPs |
52
+ | `aegis.middleware.RateLimitMiddleware` | Returns 429 for rate-limited IPs, with optional auto-block |
53
+ | `aegis.rate_limit` | Cache-backed rate limit engine |
54
+ | `aegis/templates/aegis/403.html` | Template for blocked requests; receives `retry_after_human` |
55
+ | `aegis/templates/aegis/429.html` | Template for rate-limited requests; receives `retry_after_human` |
56
+
57
+ ## How It Works
58
+
59
+ On every request, each middleware runs in order:
60
+
61
+ ### BlockedIPMiddleware
62
+
63
+ 1. Resolves the client IP via `get_client_ip(request)`.
64
+ 2. Looks up an active `BlockedIP` record (`expires_at > now` or `expires_at IS NULL` for permanent blocks).
65
+ 3. If none exists, the request proceeds normally.
66
+ 4. If one exists:
67
+ - Atomically increments `tally` and updates `last_seen` using `F()`.
68
+ - Computes seconds remaining until `expires_at` (or `None` for permanent blocks).
69
+ - Renders `aegis/403.html` with a humanized duration (or `"the foreseeable future"` for permanent blocks).
70
+ - Returns `403 Forbidden` with a `Retry-After` header (omitted for permanent blocks).
71
+
72
+ ### RateLimitMiddleware
73
+
74
+ 1. Checks if the request path should be rate limited (respects `AEGIS_RATE_LIMIT_SKIP_PATHS`).
75
+ 2. Resolves the client IP.
76
+ 3. Increments a cache-based counter for the current time window.
77
+ 4. If the count exceeds `AEGIS_RATE_LIMIT_REQUESTS`:
78
+ - Increments a violation counter.
79
+ - Optionally auto-blocks the IP via `BlockedIP` if violations reach `AEGIS_RATE_LIMIT_AUTO_BLOCK_THRESHOLD`.
80
+ - Returns `429 Too Many Requests` with a `Retry-After` header.
81
+ 5. If within limits, allows the request (at most one violation counted per time window).
82
+
83
+ ## Response Format
84
+
85
+ ### 403 Forbidden
86
+
87
+ - **Status:** `403 Forbidden`
88
+ - **Header:** `Retry-After: <seconds>` — minimum `1` (omitted for permanent blocks)
89
+ - **Body:** Rendered HTML from `aegis/403.html`
90
+
91
+ ### 429 Too Many Requests
92
+
93
+ - **Status:** `429 Too Many Requests`
94
+ - **Header:** `Retry-After: <seconds>`
95
+ - **Body:** Rendered HTML from `aegis/429.html`
96
+
97
+ ## Humanized Durations
98
+
99
+ The `_humanize` helper converts seconds into the largest unit that fits, with correct pluralization:
100
+
101
+ | Range | Output |
102
+ | ------- | -------------- |
103
+ | `< 60s` | `"42 seconds"` |
104
+ | `< 60m` | `"7 minutes"` |
105
+ | `< 24h` | `"3 hours"` |
106
+ | `≥ 24h` | `"2 days"` |
107
+
108
+ ## Configuration
109
+
110
+ All settings are optional with sensible defaults.
111
+
112
+ ### Rate Limiting
113
+
114
+ | Setting | Default | Description |
115
+ | ------------------------------------- | -------- | --------------------------------------------- |
116
+ | `AEGIS_RATE_LIMIT_ENABLED` | `True` | Toggle rate limiting on/off |
117
+ | `AEGIS_RATE_LIMIT_REQUESTS` | `100` | Max requests per window |
118
+ | `AEGIS_RATE_LIMIT_WINDOW` | `60` | Time window in seconds |
119
+ | `AEGIS_RATE_LIMIT_AUTO_BLOCK` | `True` | Auto-block IPs that exceed violation threshold |
120
+ | `AEGIS_RATE_LIMIT_AUTO_BLOCK_THRESHOLD` | `5` | Consecutive limited windows before auto-block |
121
+ | `AEGIS_RATE_LIMIT_BLOCK_DURATION` | `3600` | Block duration in seconds (default 1 hour) |
122
+ | `AEGIS_RATE_LIMIT_SKIP_PATHS` | `[]` | Path prefixes to exclude from rate limiting |
123
+
124
+ ## Adding a Block
125
+
126
+ Create a `BlockedIP` row anywhere in your code — a signal, management command, admin action, or rate-limit handler:
127
+
128
+ ```python
129
+ from datetime import timedelta
130
+ from django.utils import timezone
131
+ from aegis.models import BlockedIP
132
+
133
+ BlockedIP.objects.create(
134
+ ip="203.0.113.45",
135
+ expires_at=timezone.now() + timedelta(hours=1),
136
+ )
137
+ ```
138
+
139
+ For a permanent block, omit `expires_at` (set it to `None`):
140
+
141
+ ```python
142
+ BlockedIP.objects.create(ip="203.0.113.45", reason="permanent ban")
143
+ ```
144
+
145
+ The block takes effect on the offender's next request — no restart or cache invalidation needed.
146
+
147
+ ## Running Tests
148
+
149
+ ```bash
150
+ pytest
151
+ ```
152
+
153
+ Tests are in `aegis/tests/` and cover:
154
+
155
+ | File | What it tests |
156
+ | ----------------------- | ---------------------------------------------------- |
157
+ | `test_models.py` | `BlockedIP.is_active` for permanent, future, expired |
158
+ | `test_utils.py` | `get_client_ip` with direct and proxied requests |
159
+ | `test_middleware.py` | 403 responses, pass-through, tally tracking |
160
+ | `test_rate_limit.py` | Rate limit counting, window slots, violations (one per window), 429 responses, auto-block, skip paths, permanent blocks |
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.9.21,<=0.12.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "django-aegis"
7
+ version = "0.0.1"
8
+ description = "IP based blocker with rate limiting"
9
+ readme = "README.md"
10
+ authors = [{ name = "loowtide" }]
11
+ license = "MIT"
12
+ license-files = ["LICEN[CS]E*"]
13
+ requires-python = ">=3.13"
14
+ dependencies = [
15
+ "django>=6.0.5",
16
+ "django-stubs>=6.0.4",
17
+ "pytest-django>=4.12.0",
18
+ "python-dotenv>=1.2.2",
19
+ ]
20
+ keywords = ["aegis", "blocker", "ip", "rate limiting", "api"]
21
+ classifiers = [
22
+ "Development Status :: 3 - Alpha",
23
+ "Intended Audience :: Developers",
24
+ "Topic :: Software Development :: Libraries",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ ]
31
+
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/loowtide/aegis"
35
+
36
+ [tool.uv.build-backend]
37
+ module-name = "aegis"
38
+
39
+ [tool.mypy]
40
+ plugins = ["mypy_django_plugin.main"]
41
+ strict = true
42
+
43
+ [tool.django-stubs]
44
+ django_settings_module = "config.settings"
45
+
46
+ [dependency-groups]
47
+ dev = ["mypy>=2.1.0", "django-stubs[compatible-mypy]>=5.1.0"]
File without changes
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from django.contrib import admin
4
+ from django.db.models.query import QuerySet
5
+ from django.http import HttpRequest
6
+ from django.utils import timezone
7
+
8
+ from aegis.models import BlockedIP
9
+
10
+
11
+ class ActiveStatusFilter(admin.SimpleListFilter):
12
+ title = "status"
13
+ parameter_name = "status"
14
+
15
+ def lookups(
16
+ self, request: HttpRequest, model_admin: admin.ModelAdmin[BlockedIP]
17
+ ) -> list[tuple[str, str]]:
18
+ return [
19
+ ("active", "Active"),
20
+ ("expired", "Expired"),
21
+ ("permanent", "Permanent"),
22
+ ]
23
+
24
+ def queryset(
25
+ self, request: HttpRequest, queryset: QuerySet[BlockedIP]
26
+ ) -> QuerySet[BlockedIP]:
27
+ now = timezone.now()
28
+ if self.value() == "active":
29
+ return queryset.filter(expires_at__gt=now)
30
+ if self.value() == "expired":
31
+ return queryset.filter(expires_at__lte=now)
32
+ if self.value() == "permanent":
33
+ return queryset.filter(expires_at__isnull=True)
34
+ return queryset
35
+
36
+
37
+ @admin.register(BlockedIP)
38
+ class BlockedIPAdmin(admin.ModelAdmin): # type: ignore[type-arg]
39
+ list_display = ["ip", "reason", "blocked_at", "expires_at", "last_seen"]
40
+ search_fields = [
41
+ "ip",
42
+ ]
43
+ list_filter = [ActiveStatusFilter, "blocked_at"]
44
+ ordering = ["-blocked_at"]
@@ -0,0 +1,5 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class AegisConfig(AppConfig):
5
+ name = "aegis"
@@ -0,0 +1,107 @@
1
+ from collections.abc import Callable
2
+
3
+ from django.conf import settings
4
+ from django.db.models import F, Q
5
+ from django.http import HttpRequest, HttpResponse
6
+ from django.template.loader import render_to_string
7
+ from django.utils import timezone
8
+
9
+ from aegis.models import BlockedIP
10
+ from aegis.rate_limit import (
11
+ auto_block,
12
+ increment_violations,
13
+ is_rate_limited,
14
+ reset_if_clean_window,
15
+ # reset_if_clean_window,
16
+ )
17
+ from aegis.utils import get_client_ip
18
+
19
+
20
+ def _humanize(seconds: int) -> str:
21
+ if seconds < 60:
22
+ return f"{seconds} second{'s' if seconds != 1 else ''}"
23
+ if seconds < 3600:
24
+ m = seconds // 60
25
+ return f"{m} minute{'s' if m != 1 else ''}"
26
+ if seconds < 86400:
27
+ h = seconds // 3600
28
+ return f"{h} hour{'s' if h != 1 else ''}"
29
+ d = seconds // 86400
30
+ return f"{d} day{'s' if d != 1 else ''}"
31
+
32
+
33
+ class BlockedIPMiddleware:
34
+ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
35
+ self.get_response = get_response
36
+
37
+ def __call__(self, request: HttpRequest) -> HttpResponse:
38
+ ip = get_client_ip(request)
39
+ if not ip:
40
+ return self.get_response(request)
41
+ now = timezone.now()
42
+ entry = BlockedIP.objects.filter(
43
+ Q(expires_at__isnull=True) | Q(expires_at__gt=now), ip=ip
44
+ ).first()
45
+ if entry:
46
+ BlockedIP.objects.filter(pk=entry.pk).update(
47
+ last_seen=now, tally=F("tally") + 1
48
+ )
49
+ if entry.expires_at is None:
50
+ retry_after = None
51
+ else:
52
+ retry_after = max(1, int((entry.expires_at - now).total_seconds()))
53
+ html = render_to_string(
54
+ "aegis/403.html",
55
+ {
56
+ "retry_after_human": _humanize(retry_after)
57
+ if retry_after
58
+ else "the foreseeable future"
59
+ },
60
+ request=request,
61
+ )
62
+ response = HttpResponse(html, status=403)
63
+ if retry_after is not None:
64
+ response["Retry-After"] = str(retry_after)
65
+ return response
66
+ return self.get_response(request)
67
+
68
+
69
+ class RateLimitMiddleware:
70
+ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
71
+ self.get_response = get_response
72
+
73
+ def __call__(self, request: HttpRequest) -> HttpResponse:
74
+ if not self._should_rate_limit(request):
75
+ return self.get_response(request)
76
+ ip = get_client_ip(request)
77
+ if not ip:
78
+ return self.get_response(request)
79
+ window = getattr(settings, "AEGIS_RATE_LIMIT_WINDOW", 60)
80
+ max_requests = getattr(settings, "AEGIS_RATE_LIMIT_REQUESTS", 100)
81
+ auto_block_enabled = getattr(settings, "AEGIS_RATE_LIMIT_AUTO_BLOCK", True)
82
+ threshold = getattr(settings, "AEGIS_RATE_LIMIT_AUTO_BLOCK_THRESHOLD", 5)
83
+ block_duration = getattr(settings, "AEGIS_RATE_LIMIT_BLOCK_DURATION", 3600)
84
+ limited, _ = is_rate_limited(ip, max_requests, window)
85
+ if limited:
86
+ violations = increment_violations(ip, window, threshold)
87
+ if auto_block_enabled and violations >= threshold:
88
+ auto_block(ip, block_duration)
89
+ retry_after = window
90
+ html = render_to_string(
91
+ "aegis/429.html",
92
+ {"retry_after_human": _humanize(retry_after)},
93
+ request=request,
94
+ )
95
+ response = HttpResponse(html, status=429)
96
+ response["Retry-After"] = str(retry_after)
97
+ return response
98
+ else:
99
+ reset_if_clean_window(ip, window, threshold)
100
+ return self.get_response(request)
101
+
102
+ def _should_rate_limit(self, request: HttpRequest) -> bool:
103
+ if not getattr(settings, "AEGIS_RATE_LIMIT_ENABLED", True):
104
+ return False
105
+ skip_paths = getattr(settings, "AEGIS_RATE_LIMIT_SKIP_PATHS", [])
106
+ path = request.path_info
107
+ return not any(path.startswith(p) for p in skip_paths)
@@ -0,0 +1,44 @@
1
+ # Generated by Django 6.0.5 on 2026-05-23 12:48
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ initial = True
8
+
9
+ dependencies = []
10
+
11
+ operations = [
12
+ migrations.CreateModel(
13
+ name="BlockedIP",
14
+ fields=[
15
+ (
16
+ "id",
17
+ models.BigAutoField(
18
+ auto_created=True,
19
+ primary_key=True,
20
+ serialize=False,
21
+ verbose_name="ID",
22
+ ),
23
+ ),
24
+ (
25
+ "ip",
26
+ models.GenericIPAddressField(
27
+ db_index=True, unique=True, verbose_name="IP address"
28
+ ),
29
+ ),
30
+ ("reason", models.TextField()),
31
+ ("blocked_at", models.DateTimeField(auto_now_add=True)),
32
+ (
33
+ "expires_at",
34
+ models.DateTimeField(blank=True, db_index=True, null=True),
35
+ ),
36
+ ("tally", models.PositiveIntegerField(default=0)),
37
+ ("last_seen", models.DateTimeField(blank=True, null=True)),
38
+ ],
39
+ options={
40
+ "verbose_name": "Blocked IP",
41
+ "ordering": ["-blocked_at"],
42
+ },
43
+ ),
44
+ ]
File without changes
@@ -0,0 +1,24 @@
1
+ from django.db import models
2
+ from django.utils import timezone
3
+
4
+
5
+ class BlockedIP(models.Model):
6
+ ip = models.GenericIPAddressField(unique=True, verbose_name="IP address")
7
+ reason = models.TextField()
8
+ blocked_at = models.DateTimeField(auto_now_add=True)
9
+ expires_at = models.DateTimeField(null=True, blank=True, db_index=True)
10
+ tally = models.PositiveIntegerField(default=0)
11
+ last_seen = models.DateTimeField(null=True, blank=True)
12
+
13
+ class Meta:
14
+ verbose_name = "Blocked IP"
15
+ ordering = ["-blocked_at"]
16
+
17
+ def __str__(self) -> str:
18
+ return f"{self.ip}"
19
+
20
+ @property
21
+ def is_active(self) -> bool:
22
+ if self.expires_at is None:
23
+ return True # permanent block
24
+ return timezone.now() < self.expires_at
@@ -0,0 +1,111 @@
1
+ from datetime import timedelta
2
+
3
+ from django.core.cache import cache
4
+ from django.utils import timezone
5
+
6
+ from aegis.models import BlockedIP
7
+
8
+
9
+ def _window_slot(window: int) -> int:
10
+ now = int(timezone.now().timestamp())
11
+ return now - (now % window)
12
+
13
+
14
+ def _rate_limit_key(ip: str, window: int) -> str:
15
+ slot = _window_slot(window)
16
+ return f"aegis:{ip}:{slot}"
17
+
18
+
19
+ def _violation_key(ip: str) -> str:
20
+ return f"aegis:{ip}:violations"
21
+
22
+
23
+ def _clean_streak_key(ip: str) -> str:
24
+ return f"aegis:{ip}:clean_streak"
25
+
26
+
27
+ def _last_seen_window_key(ip: str) -> str:
28
+ return f"aegis:{ip}:last_seen_window"
29
+
30
+
31
+ def is_rate_limited(
32
+ ip: str, max_requests: int = 100, window: int = 60
33
+ ) -> tuple[bool, int]:
34
+ key = _rate_limit_key(ip, window)
35
+ if cache.add(key, 1, timeout=window * 2):
36
+ return False, 1
37
+ try:
38
+ count = cache.incr(key)
39
+ except ValueError:
40
+ cache.add(key, 1, timeout=window * 2)
41
+ count = 1
42
+ return count > max_requests, count
43
+
44
+
45
+ def get_violations(ip: str) -> int:
46
+ return cache.get(_violation_key(ip)) or 0
47
+
48
+
49
+ def increment_violations(ip: str, window: int, threshold: int) -> int:
50
+ key = _violation_key(ip)
51
+ timeout = window * threshold * 2
52
+ slot = _window_slot(window)
53
+ last_key = _last_seen_window_key(ip)
54
+ if cache.get(last_key) == slot:
55
+ return get_violations(ip)
56
+ if cache.add(key, 1, timeout=timeout):
57
+ v = 1
58
+ else:
59
+ try:
60
+ v = cache.incr(key)
61
+ except ValueError:
62
+ cache.add(key, 1, timeout=timeout)
63
+ v = 1
64
+ cache.touch(key, timeout)
65
+ cache.set(_clean_streak_key(ip), 0, timeout=timeout)
66
+ cache.set(last_key, slot, timeout=timeout)
67
+ return v
68
+
69
+
70
+ def reset_if_clean_window(ip: str, window: int, threshold: int) -> int:
71
+ key = _violation_key(ip)
72
+ streak = _clean_streak_key(ip)
73
+ last_key = _last_seen_window_key(ip)
74
+ timeout = window * threshold * 2
75
+ slot = _window_slot(window)
76
+ if cache.get(last_key) == slot:
77
+ return get_violations(ip)
78
+
79
+ cache.set(last_key, slot, timeout=timeout)
80
+ if get_violations(ip) == 0:
81
+ return 0
82
+ if cache.add(streak, 1, timeout=timeout):
83
+ streak_cnt = 1
84
+ else:
85
+ try:
86
+ streak_cnt = cache.incr(streak)
87
+ except ValueError:
88
+ cache.add(streak, 1, timeout=timeout)
89
+ streak_cnt = 1
90
+ cache.touch(streak, timeout)
91
+
92
+ if streak_cnt >= threshold:
93
+ cache.set(key, 0, timeout=timeout)
94
+ cache.set(streak, 0, timeout=timeout)
95
+ return 0
96
+
97
+ return get_violations(ip)
98
+
99
+
100
+ def auto_block(ip: str, duration: int, extend_active: bool = False) -> BlockedIP:
101
+ reason = "Rate limit violation threshold exceeded"
102
+ expires_at = timezone.now() + timedelta(seconds=duration)
103
+ blocked, _ = BlockedIP.objects.get_or_create(
104
+ ip=ip,
105
+ defaults={"reason": reason, "expires_at": expires_at},
106
+ )
107
+ if not blocked.expires_at or blocked.expires_at < timezone.now() or extend_active:
108
+ blocked.expires_at = expires_at
109
+ blocked.reason = reason
110
+ blocked.save(update_fields=["expires_at", "reason"])
111
+ return blocked
@@ -0,0 +1,56 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Just a moment</title>
7
+ <style>
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+ body {
12
+ font-family:
13
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
14
+ sans-serif;
15
+ background: #fafaf9;
16
+ color: #1c1917;
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ min-height: 100vh;
21
+ margin: 0;
22
+ padding: 1.5rem;
23
+ line-height: 1.6;
24
+ }
25
+ main {
26
+ max-width: 420px;
27
+ text-align: center;
28
+ }
29
+ h1 {
30
+ font-size: 1.5rem;
31
+ font-weight: 600;
32
+ margin: 0 0 1rem;
33
+ }
34
+ p {
35
+ color: #57534e;
36
+ margin: 0.5rem 0;
37
+ }
38
+ .retry {
39
+ margin-top: 1.5rem;
40
+ color: #78716c;
41
+ font-size: 0.9rem;
42
+ }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <main>
47
+ <h1>Just a moment</h1>
48
+ <p>
49
+ We've paused requests from your connection for a little while.
50
+ </p>
51
+ {% if retry_after_human %}
52
+ <p class="retry">You can try again in {{ retry_after_human }}.</p>
53
+ {% endif %}
54
+ </main>
55
+ </body>
56
+ </html>
@@ -0,0 +1,54 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Too Many Requests</title>
7
+ <style>
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+ body {
12
+ font-family:
13
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
14
+ sans-serif;
15
+ background: #fafaf9;
16
+ color: #1c1917;
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ min-height: 100vh;
21
+ margin: 0;
22
+ padding: 1.5rem;
23
+ line-height: 1.6;
24
+ }
25
+ main {
26
+ max-width: 420px;
27
+ text-align: center;
28
+ }
29
+ h1 {
30
+ font-size: 1.5rem;
31
+ font-weight: 600;
32
+ margin: 0 0 1rem;
33
+ }
34
+ p {
35
+ color: #57534e;
36
+ margin: 0.5rem 0;
37
+ }
38
+ .retry {
39
+ margin-top: 1.5rem;
40
+ color: #78716c;
41
+ font-size: 0.9rem;
42
+ }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <main>
47
+ <h1>Too Many Requests</h1>
48
+ <p>You've made too many requests. Please slow down.</p>
49
+ {% if retry_after_human %}
50
+ <p class="retry">You can try again in {{ retry_after_human }}.</p>
51
+ {% endif %}
52
+ </main>
53
+ </body>
54
+ </html>
@@ -0,0 +1,2 @@
1
+ # Automatically created by ruff.
2
+ *
@@ -0,0 +1 @@
1
+ Signature: 8a477f597d28d172789f06886806bc55
File without changes
@@ -0,0 +1,46 @@
1
+ from datetime import timedelta
2
+
3
+ import pytest
4
+ from django.test import Client
5
+ from django.utils import timezone
6
+
7
+ from aegis.models import BlockedIP
8
+
9
+
10
+ @pytest.mark.django_db
11
+ class TestMiddleware:
12
+ def test_middleware_returns_403_for_blocked_ip(self) -> None:
13
+ """Middleware returns 403 for blocked ip."""
14
+
15
+ future = timezone.now() + timedelta(hours=1)
16
+ BlockedIP.objects.create(ip="127.0.0.1", reason="test", expires_at=future)
17
+ client = Client(REMOTE_ADDR="127.0.0.1")
18
+ response = client.get("/admin/")
19
+ assert response.status_code == 403
20
+
21
+ def test_middlware_passes_through_non_block(self) -> None:
22
+ """Middleware let pass the non blocked ips."""
23
+
24
+ client = Client(REMOTE_ADDR="127.0.0.1")
25
+ response = client.get("/admin/")
26
+ assert response.status_code == 302
27
+
28
+ def test_middleware_passes_through_expired_ips(self) -> None:
29
+ """Middleware also let expired ips to pass through."""
30
+
31
+ past = timezone.now() - timedelta(hours=1)
32
+ BlockedIP.objects.create(ip="127.0.0.1", expires_at=past)
33
+ client = Client(REMOTE_ADDR="127.0.0.1")
34
+ response = client.get("/admin/")
35
+ assert response.status_code == 302
36
+
37
+ def test_middleware_tally_increment(self) -> None:
38
+ """Tally increments on blocked ips"""
39
+
40
+ future = timezone.now() + timedelta(hours=1)
41
+ blocked = BlockedIP.objects.create(ip="127.0.0.1", expires_at=future)
42
+ client = Client(REMOTE_ADDR="127.0.0.1")
43
+ for i in range(4):
44
+ client.get("/admin/")
45
+ blocked.refresh_from_db()
46
+ assert blocked.tally == 4
@@ -0,0 +1,32 @@
1
+ from datetime import timedelta
2
+
3
+ import pytest
4
+ from django.utils import timezone
5
+
6
+ from aegis.models import BlockedIP
7
+
8
+
9
+ @pytest.mark.django_db
10
+ class TestBlockedIP:
11
+ def test_is_active_when_expires_at_is_none(self) -> None:
12
+ """Permanent Blocks are always active."""
13
+
14
+ blocked = BlockedIP.objects.create(
15
+ ip="127.0.0.3",
16
+ reason="spam",
17
+ )
18
+ assert blocked.is_active
19
+
20
+ def test_is_active_when_expires_at_in_future(self) -> None:
21
+ """BLocks with future expiry are still active."""
22
+
23
+ future = timezone.now() + timedelta(hours=1)
24
+ blocked = BlockedIP.objects.create(ip="127.0.0.2", expires_at=future)
25
+ assert blocked.is_active
26
+
27
+ def test_is_active_expires_at_in_past(self) -> None:
28
+ """Blocks past their expiry are not active(expired)."""
29
+
30
+ past = timezone.now() - timedelta(hours=1)
31
+ blocked = BlockedIP.objects.create(ip="127.0.0.3", expires_at=past)
32
+ assert not blocked.is_active
@@ -0,0 +1,194 @@
1
+ from datetime import timedelta
2
+ from unittest.mock import patch
3
+
4
+ from django.conf import settings
5
+ from django.core.cache import cache
6
+ from django.test import Client, TestCase, override_settings
7
+ from django.utils import timezone
8
+
9
+ from aegis.models import BlockedIP
10
+ from aegis.rate_limit import (
11
+ _clean_streak_key,
12
+ _violation_key,
13
+ _window_slot,
14
+ increment_violations,
15
+ is_rate_limited,
16
+ reset_if_clean_window,
17
+ )
18
+
19
+
20
+ @override_settings(AEGIS_RATE_LIMIT_REQUESTS=100)
21
+ class TestRateLimit(TestCase):
22
+ def setUp(self) -> None:
23
+ cache.clear()
24
+
25
+ def test_under_limit_passes(self) -> None:
26
+ limited, count = is_rate_limited("127.0.0.14", 100, 60)
27
+ assert not limited
28
+ assert count == 1
29
+
30
+ def test_over_limit_detected(self) -> None:
31
+ ip = "127.0.0.3"
32
+ window = 60
33
+ max_req = 3
34
+ for _ in range(max_req):
35
+ is_rate_limited(ip, max_req, window)
36
+ limited, count = is_rate_limited(ip, max_req, window)
37
+ assert limited
38
+ assert count == 4
39
+
40
+ def test_window_slot_changes(self) -> None:
41
+ slot_a = _window_slot(60)
42
+ with patch("aegis.rate_limit.timezone") as mock_tz:
43
+ mock_tz.now.return_value = timezone.now() + timedelta(seconds=120)
44
+ mock_tz.timedelta = timedelta
45
+ slot_b = _window_slot(60)
46
+ assert slot_b > slot_a
47
+
48
+ def test_violations_persist_without_clean_windows(self) -> None:
49
+ ip = "127.0.0.99"
50
+ window = 60
51
+ threshold = 5
52
+ base = _window_slot(window)
53
+ with patch("aegis.rate_limit._window_slot") as mock_slot:
54
+ mock_slot.return_value = base
55
+ assert increment_violations(ip, window, threshold) == 1
56
+ mock_slot.return_value = base + 2 * window
57
+ assert increment_violations(ip, window, threshold) == 2
58
+ mock_slot.return_value = base + 5 * window
59
+ assert increment_violations(ip, window, threshold) == 3
60
+
61
+ def test_one_violation_per_window(self) -> None:
62
+ ip = "127.0.0.98"
63
+ window = 60
64
+ threshold = 5
65
+ with patch("aegis.rate_limit._window_slot") as mock_slot:
66
+ mock_slot.return_value = _window_slot(window)
67
+ assert increment_violations(ip, window, threshold) == 1
68
+ assert increment_violations(ip, window, threshold) == 1
69
+
70
+
71
+ @override_settings(AEGIS_RATE_LIMIT_REQUESTS=100)
72
+ class TestAutoBlock(TestCase):
73
+ def test_under_limit_no_block(self) -> None:
74
+ client = Client(REMOTE_ADDR="127.0.0.10")
75
+ for _ in range(50):
76
+ resp = client.get("/some-page/")
77
+ assert resp.status_code in (200, 404)
78
+ assert BlockedIP.objects.filter(ip="127.0.0.10").count() == 0
79
+
80
+ def test_rate_limit_returns_429(self) -> None:
81
+ ip = "127.0.0.11"
82
+ client = Client(REMOTE_ADDR=ip)
83
+ resp = None
84
+ for _ in range(101):
85
+ resp = client.get("/some-page/")
86
+ assert resp is not None
87
+ assert resp.status_code == 429
88
+ assert "Retry-After" in resp
89
+
90
+ def test_skip_paths_bypass_rate_limit(self) -> None:
91
+ client = Client(REMOTE_ADDR="127.0.0.12")
92
+ with patch.object(settings, "AEGIS_RATE_LIMIT_SKIP_PATHS", ["/skip-me/"]):
93
+ resp = client.get("/skip-me/")
94
+ assert resp.status_code != 429
95
+
96
+ def test_auto_block_creates_blockedip(self) -> None:
97
+ ip = "127.0.0.13"
98
+ window = 60
99
+ threshold = 3
100
+ duration = 300
101
+
102
+ from aegis.rate_limit import auto_block
103
+
104
+ for _ in range(threshold):
105
+ increment_violations(ip, window, threshold)
106
+
107
+ auto_block(ip, duration)
108
+ entry = BlockedIP.objects.filter(ip=ip).first()
109
+ assert entry is not None
110
+ assert entry.reason == "Rate limit violation threshold exceeded"
111
+ assert entry.expires_at is not None
112
+ assert entry.expires_at > timezone.now()
113
+
114
+ def test_existing_blocked_ips_get_403_via_middleware(self) -> None:
115
+ ip = "127.0.0.14"
116
+ BlockedIP.objects.create(
117
+ ip=ip,
118
+ reason="auto-blocked",
119
+ expires_at=timezone.now() + timedelta(hours=1),
120
+ )
121
+ client = Client(REMOTE_ADDR=ip)
122
+ resp = client.get("/some-page/")
123
+ assert resp.status_code == 403
124
+
125
+ def test_permanent_block_returns_403(self) -> None:
126
+ ip = "127.0.0.4"
127
+ BlockedIP.objects.create(ip=ip, reason="permanent", expires_at=None)
128
+ client = Client(REMOTE_ADDR=ip)
129
+ resp = client.get("/some-page")
130
+ assert resp.status_code == 403
131
+
132
+
133
+ class TestCleanWindowReset(TestCase):
134
+ def setUp(self) -> None:
135
+ cache.clear()
136
+
137
+ def test_reset_after_n_consecutive_clean_windows(self) -> None:
138
+ ip = "127.0.0.50"
139
+ window = 60
140
+ threshold = 3
141
+ base = _window_slot(window)
142
+ with patch("aegis.rate_limit._window_slot") as mock_slot:
143
+ mock_slot.return_value = base
144
+ increment_violations(ip, window, threshold)
145
+ mock_slot.return_value = base + window
146
+ increment_violations(ip, window, threshold)
147
+ assert cache.get(_violation_key(ip)) == 2
148
+
149
+ mock_slot.return_value = base + 2 * window
150
+ reset_if_clean_window(ip, window, threshold)
151
+ mock_slot.return_value = base + 3 * window
152
+ reset_if_clean_window(ip, window, threshold)
153
+ mock_slot.return_value = base + 4 * window
154
+ result = reset_if_clean_window(ip, window, threshold)
155
+ assert result == 0
156
+ assert cache.get(_violation_key(ip)) in (0, None)
157
+
158
+ def test_violation_breaks_clean_streak(self) -> None:
159
+ ip = "127.0.0.51"
160
+ window = 60
161
+ threshold = 3
162
+ base = _window_slot(window)
163
+ with patch("aegis.rate_limit._window_slot") as mock_slot:
164
+ mock_slot.return_value = base
165
+ increment_violations(ip, window, threshold)
166
+ mock_slot.return_value = base + window
167
+ reset_if_clean_window(ip, window, threshold)
168
+ mock_slot.return_value = base + 2 * window
169
+ reset_if_clean_window(ip, window, threshold)
170
+ mock_slot.return_value = base + 3 * window
171
+ increment_violations(ip, window, threshold)
172
+ assert cache.get(_clean_streak_key(ip)) == 0
173
+
174
+ def test_no_op_on_ip_with_no_violation_history(self) -> None:
175
+ ip = "127.0.0.60"
176
+ window = 60
177
+ threshold = 3
178
+ result = reset_if_clean_window(ip, window, threshold)
179
+ assert result == 0
180
+ assert cache.get(_clean_streak_key(ip)) is None
181
+
182
+ def test_duplicate_clean_calls_same_window_are_noop(self) -> None:
183
+ ip = "127.0.0.61"
184
+ window = 60
185
+ threshold = 3
186
+ base = _window_slot(window)
187
+ with patch("aegis.rate_limit._window_slot") as mock_slot:
188
+ mock_slot.return_value = base
189
+ increment_violations(ip, window, threshold)
190
+ mock_slot.return_value = base + window
191
+ r1 = reset_if_clean_window(ip, window, threshold)
192
+ r2 = reset_if_clean_window(ip, window, threshold)
193
+ assert r1 == r2
194
+ assert cache.get(_clean_streak_key(ip)) == 1
@@ -0,0 +1,86 @@
1
+ import pytest
2
+ from django.test import RequestFactory, override_settings
3
+
4
+ from aegis.utils import _is_trusted, get_client_ip
5
+
6
+
7
+ @pytest.mark.django_db
8
+ class TestUtils:
9
+ def test_get_client_ip_for_direct_conn(self) -> None:
10
+ """get ip address for direct connection."""
11
+
12
+ factory = RequestFactory()
13
+ request = factory.get("/", REMOTE_ADDR="127.0.0.2")
14
+ assert get_client_ip(request) == "127.0.0.2"
15
+
16
+ def test_get_client_ip_from_x_forwarded_for_trusted(self) -> None:
17
+ factory = RequestFactory()
18
+ request = factory.get(
19
+ "/", REMOTE_ADDR="127.0.0.1", HTTP_X_FORWARDED_FOR="223.20.1.0"
20
+ )
21
+ assert get_client_ip(request) == "223.20.1.0"
22
+
23
+ def test_get_client_ip_from_x_forwarded_for_untrusted(self) -> None:
24
+ factory = RequestFactory()
25
+ request = factory.get(
26
+ "/", REMOTE_ADDR="127.0.0.2", HTTP_X_FORWARDED_FOR="10.0.0.2"
27
+ )
28
+ assert get_client_ip(request) == "127.0.0.2"
29
+
30
+
31
+ @pytest.mark.django_db
32
+ class TestIsTrustedIPv4Only:
33
+ def test_trusts_loopback_by_default(self) -> None:
34
+ assert _is_trusted("127.0.0.1") is True
35
+
36
+ def test_trusts_ip_in_configured_cidr(self) -> None:
37
+ with override_settings(AEGIS_TRUSTED_PROXY_NETWORKS=["10.0.0.0/8"]):
38
+ from aegis import utils
39
+
40
+ utils.TRUSTED_PROXY_NETWORKS = utils._load_trusted_networks()
41
+ assert _is_trusted("10.1.2.3") is True
42
+ assert _is_trusted("11.1.2.3") is False
43
+ from aegis import utils
44
+
45
+ utils.TRUSTED_PROXY_NETWORKS = utils._load_trusted_networks()
46
+
47
+ def test_rejects_ipv6_even_if_supplied(self) -> None:
48
+ assert _is_trusted("::1") is False
49
+
50
+ def test_rejects_garbage(self) -> None:
51
+ assert _is_trusted("not-an-ip") is False
52
+ assert _is_trusted("") is False
53
+
54
+
55
+ @pytest.mark.django_db
56
+ class TestGetClientIpSpoofing:
57
+ def test_attacker_prepended_entries_are_ignored(self) -> None:
58
+ factory = RequestFactory()
59
+ request = factory.get(
60
+ "/", REMOTE_ADDR="127.0.0.1", HTTP_X_FORWARDED_FOR="9.9.9.9, 198.51.100.7"
61
+ )
62
+ assert get_client_ip(request) == "198.51.100.7"
63
+
64
+ def test_depth_two_requires_two_hops(self, monkeypatch: pytest.MonkeyPatch) -> None:
65
+ from aegis import utils
66
+
67
+ monkeypatch.setattr(utils, "TRUSTED_PROXY_DEPTH", 2)
68
+ factory = RequestFactory()
69
+ request = factory.get(
70
+ "/",
71
+ REMOTE_ADDR="127.0.0.1",
72
+ HTTP_X_FORWARDED_FOR="9.9.9.9, 10.0.0.5, 198.51.100.7",
73
+ )
74
+ assert get_client_ip(request) == "10.0.0.5"
75
+
76
+ def test_short_header_falls_back_to_remote_addr(self) -> None:
77
+ from aegis import utils
78
+
79
+ factory = RequestFactory()
80
+ request = factory.get(
81
+ "/", REMOTE_ADDR="127.0.0.1", HTTP_X_FORWARDED_FOR="198.51.100.7"
82
+ )
83
+ with override_settings(AEGIS_TRUSTED_PROXY_DEPTH=3):
84
+ utils.TRUSTED_PROXY_DEPTH = 3
85
+ assert get_client_ip(request) == "127.0.0.1"
86
+ utils.TRUSTED_PROXY_DEPTH = 1
@@ -0,0 +1,80 @@
1
+ import ipaddress
2
+ import logging
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from django.conf import settings
7
+ from django.db.models import Q
8
+ from django.http import HttpRequest
9
+ from django.utils import timezone
10
+
11
+ from aegis.models import BlockedIP
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _load_trusted_networks() -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
17
+ raw = getattr(settings, "AEGIS_TRUSTED_PROXY_NETWORKS", ["127.0.0.1"])
18
+ networks = []
19
+ for entry in raw:
20
+ try:
21
+ networks.append(ipaddress.ip_network(entry, strict=False))
22
+ except ValueError:
23
+ logger.warning("aegis: ignoring invalid trusted proxy network %r", entry)
24
+ return networks
25
+
26
+
27
+ TRUSTED_PROXY_NETWORKS = _load_trusted_networks()
28
+ TRUSTED_PROXY_DEPTH = getattr(settings, "AEGIS_TRUSTED_PROXY_DEPTH")
29
+
30
+
31
+ def _is_trusted(ip: str) -> bool:
32
+ try:
33
+ addr = ipaddress.ip_address(ip)
34
+ except ValueError:
35
+ return False
36
+ return any(
37
+ addr in net
38
+ for net in TRUSTED_PROXY_NETWORKS
39
+ if isinstance(net, ipaddress.IPv4Network)
40
+ )
41
+
42
+
43
+ def get_client_ip(request: HttpRequest) -> Any:
44
+ remote_addr = request.META.get("REMOTE_ADDR")
45
+ if not remote_addr or not _is_trusted(remote_addr):
46
+ return remote_addr
47
+ xff = request.META.get("HTTP_X_FORWARDED_FOR")
48
+ if not xff:
49
+ return remote_addr
50
+
51
+ hops = [h.strip() for h in xff.split(",") if h.strip()]
52
+ if not hops:
53
+ return remote_addr
54
+
55
+ if TRUSTED_PROXY_DEPTH < 1:
56
+ logger.warning("aegis: AEGIS_PROXY_DEPTH <1 ,XFF ignored")
57
+ return remote_addr
58
+
59
+ if len(hops) < TRUSTED_PROXY_DEPTH:
60
+ logger.warning(
61
+ "aegis: XFF has %d hop(s), expected at least %d, falling to REMOTE_ADDR",
62
+ len(hops),
63
+ TRUSTED_PROXY_DEPTH,
64
+ )
65
+ return remote_addr
66
+
67
+ client_ip = hops[-TRUSTED_PROXY_DEPTH]
68
+ try:
69
+ ipaddress.ip_address(client_ip)
70
+ except ValueError:
71
+ logger.warning("aegis: XFF client entry %r is not valid IP", client_ip)
72
+ return remote_addr
73
+ return client_ip
74
+
75
+
76
+ def should_block(ip: str, now: datetime | None = None) -> BlockedIP | None:
77
+ now = now or timezone.now()
78
+ return BlockedIP.objects.filter(
79
+ Q(expires_at__isnull=True) | Q(expires_at__gt=now), ip=ip
80
+ ).first()
File without changes