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.
- django_aegis-0.0.1/LICENSE +21 -0
- django_aegis-0.0.1/PKG-INFO +184 -0
- django_aegis-0.0.1/README.md +160 -0
- django_aegis-0.0.1/pyproject.toml +47 -0
- django_aegis-0.0.1/src/aegis/__init__.py +0 -0
- django_aegis-0.0.1/src/aegis/admin.py +44 -0
- django_aegis-0.0.1/src/aegis/apps.py +5 -0
- django_aegis-0.0.1/src/aegis/middleware.py +107 -0
- django_aegis-0.0.1/src/aegis/migrations/0001_initial.py +44 -0
- django_aegis-0.0.1/src/aegis/migrations/__init__.py +0 -0
- django_aegis-0.0.1/src/aegis/models.py +24 -0
- django_aegis-0.0.1/src/aegis/rate_limit.py +111 -0
- django_aegis-0.0.1/src/aegis/templates/aegis/403.html +56 -0
- django_aegis-0.0.1/src/aegis/templates/aegis/429.html +54 -0
- django_aegis-0.0.1/src/aegis/tests/.ruff_cache/.gitignore +2 -0
- django_aegis-0.0.1/src/aegis/tests/.ruff_cache/0.15.13/6971223228850148326 +0 -0
- django_aegis-0.0.1/src/aegis/tests/.ruff_cache/CACHEDIR.TAG +1 -0
- django_aegis-0.0.1/src/aegis/tests/__init__.py +0 -0
- django_aegis-0.0.1/src/aegis/tests/test_middleware.py +46 -0
- django_aegis-0.0.1/src/aegis/tests/test_models.py +32 -0
- django_aegis-0.0.1/src/aegis/tests/test_rate_limit.py +194 -0
- django_aegis-0.0.1/src/aegis/tests/test_utils.py +86 -0
- django_aegis-0.0.1/src/aegis/utils.py +80 -0
- django_aegis-0.0.1/src/aegis/views.py +0 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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,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 @@
|
|
|
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
|