django-ip-path-restrict 1.0.0__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_ip_path_restrict-1.0.0/LICENSE +21 -0
- django_ip_path_restrict-1.0.0/PKG-INFO +247 -0
- django_ip_path_restrict-1.0.0/README.md +205 -0
- django_ip_path_restrict-1.0.0/django_ip_path_restrict/__init__.py +0 -0
- django_ip_path_restrict-1.0.0/django_ip_path_restrict/apps.py +6 -0
- django_ip_path_restrict-1.0.0/django_ip_path_restrict/cloudflare.py +163 -0
- django_ip_path_restrict-1.0.0/django_ip_path_restrict/management/__init__.py +0 -0
- django_ip_path_restrict-1.0.0/django_ip_path_restrict/management/commands/__init__.py +0 -0
- django_ip_path_restrict-1.0.0/django_ip_path_restrict/management/commands/update_cloudflare_ips.py +29 -0
- django_ip_path_restrict-1.0.0/django_ip_path_restrict/middleware.py +112 -0
- django_ip_path_restrict-1.0.0/django_ip_path_restrict.egg-info/PKG-INFO +247 -0
- django_ip_path_restrict-1.0.0/django_ip_path_restrict.egg-info/SOURCES.txt +17 -0
- django_ip_path_restrict-1.0.0/django_ip_path_restrict.egg-info/dependency_links.txt +1 -0
- django_ip_path_restrict-1.0.0/django_ip_path_restrict.egg-info/requires.txt +1 -0
- django_ip_path_restrict-1.0.0/django_ip_path_restrict.egg-info/top_level.txt +1 -0
- django_ip_path_restrict-1.0.0/pyproject.toml +32 -0
- django_ip_path_restrict-1.0.0/setup.cfg +4 -0
- django_ip_path_restrict-1.0.0/tests/test_command.py +72 -0
- django_ip_path_restrict-1.0.0/tests/test_middleware.py +499 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peter
|
|
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,247 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-ip-path-restrict
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Restrict Django paths to allowed IP addresses, with optional Cloudflare support.
|
|
5
|
+
Author: Peter Stavrou
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Peter
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/peterstavrou/django-ip-path-restrict
|
|
29
|
+
Project-URL: Repository, https://github.com/peterstavrou/django-ip-path-restrict
|
|
30
|
+
Classifier: Development Status :: 4 - Beta
|
|
31
|
+
Classifier: Framework :: Django
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
37
|
+
Requires-Python: >=3.11
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Requires-Dist: Django>=5.2
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
# django-ip-path-restrict
|
|
44
|
+
|
|
45
|
+
Restrict access to specific URLs so that only users with whitelisted IP addresses can access them.
|
|
46
|
+
|
|
47
|
+
Supports IPv4 and IPv6 addresses, IP ranges, and optional Cloudflare support.
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
- Python 3.11+
|
|
52
|
+
- Django 5.2+
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install django-ip-path-restrict
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
In the `settings.py` file, add the middleware near the top of `MIDDLEWARE`, preferably immediately after `SecurityMiddleware`:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
MIDDLEWARE = [
|
|
64
|
+
'django.middleware.security.SecurityMiddleware',
|
|
65
|
+
'django_ip_path_restrict.middleware.RestrictPathsByIPMiddleware',
|
|
66
|
+
# Other middleware...
|
|
67
|
+
]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Placing the middleware near the top allows unauthorised requests to be blocked early, before other middleware processes them.
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
Add `PROTECTED_PATHS` to `settings.py` to specify which URLs to restrict based on IP addresses.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
PROTECTED_PATHS = [
|
|
78
|
+
'/admin/*', # * matches /admin/ and all paths under it
|
|
79
|
+
'/login/', # Only matches /login/
|
|
80
|
+
]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Add `ALLOWED_IPS` to specify which IP addresses are allowed to access the protected URLs.
|
|
84
|
+
|
|
85
|
+
IPv4, IPv6 and CIDR IP ranges are supported:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
ALLOWED_IPS = [
|
|
89
|
+
'203.0.113.10', # IPv4 address
|
|
90
|
+
'2001:db8::10', # IPv6 address
|
|
91
|
+
'198.51.100.0/24', # IPv4 address range
|
|
92
|
+
]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Replace these example IP addresses with the addresses you want to allow.
|
|
96
|
+
|
|
97
|
+
Users who try to access protected URLs from an IP address that is not in `ALLOWED_IPS` will receive a 404 Not Found response.
|
|
98
|
+
|
|
99
|
+
All other URLs remain accessible as normal.
|
|
100
|
+
|
|
101
|
+
If your project already uses django-environ or another settings library, you can load `ALLOWED_IPS` from an environment variable instead.
|
|
102
|
+
|
|
103
|
+
### Local development
|
|
104
|
+
|
|
105
|
+
IP restrictions apply regardless of `DEBUG`. To access protected URLs locally, add the localhost IP addresses to `ALLOWED_IPS` in your development settings:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
ALLOWED_IPS = [
|
|
109
|
+
'127.0.0.1', # IPv4 localhost
|
|
110
|
+
'::1', # IPv6 localhost
|
|
111
|
+
]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Keep these addresses in your development settings rather than adding them to your production allowlist.
|
|
115
|
+
|
|
116
|
+
## Path matching
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
| Pattern | Paths matched |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| `/admin` | `/admin` only |
|
|
122
|
+
| `/admin/` | `/admin/` only |
|
|
123
|
+
| `/admin/*` | `/admin/` and all paths under it, but not `/admin` |
|
|
124
|
+
| `/admin*` | All paths starting with `/admin`, including `/administration` |
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
For a standard Django admin installation, use `/admin/*`.
|
|
128
|
+
|
|
129
|
+
The only supported wildcard is `*`, which must appear at the end of a path.
|
|
130
|
+
|
|
131
|
+
## Use with a reverse proxy
|
|
132
|
+
|
|
133
|
+
When Nginx, Apache, LiteSpeed, or another reverse proxy sits in front of Django, `REMOTE_ADDR` may contain the proxy's IP address instead of the visitor's IP address.
|
|
134
|
+
|
|
135
|
+
Add the IP addresses of trusted reverse proxies to `IP_RESTRICT_TRUSTED_PROXY_IPS` in `settings.py`:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
IP_RESTRICT_TRUSTED_PROXY_IPS = [
|
|
139
|
+
'127.0.0.1', # IPv4 localhost
|
|
140
|
+
'::1', # IPv6 localhost
|
|
141
|
+
]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
These addresses are for reverse proxies running on the same server as Django. If your proxy runs on another server, use its IP address instead.
|
|
145
|
+
|
|
146
|
+
The middleware only trusts `X-Real-IP` when `REMOTE_ADDR` belongs to a trusted proxy. Otherwise, the header is ignored. If a trusted proxy does not provide a valid `X-Real-IP`, access to protected URLs is denied.
|
|
147
|
+
|
|
148
|
+
For Nginx, add the following line to the location block that forwards requests to Django:
|
|
149
|
+
|
|
150
|
+
```nginx
|
|
151
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Do not add `0.0.0.0/0` or `::/0` to `IP_RESTRICT_TRUSTED_PROXY_IPS`. Ensure the Django application server is not directly accessible from untrusted networks.
|
|
155
|
+
|
|
156
|
+
This feature supports TCP-connected proxies, not Unix sockets.
|
|
157
|
+
|
|
158
|
+
## Use with Cloudflare
|
|
159
|
+
|
|
160
|
+
Without Cloudflare or another reverse proxy, Django normally receives the visitor's IP address directly.
|
|
161
|
+
|
|
162
|
+
When your domain's DNS record is set to **Proxied** (orange cloud) in Cloudflare, your server receives a Cloudflare IP address instead of the visitor's IP address.
|
|
163
|
+
|
|
164
|
+
Cloudflare includes the visitor's original IP address in the `CF-Connecting-IP` request header.
|
|
165
|
+
|
|
166
|
+
Without the Cloudflare configuration, `ALLOWED_IPS` cannot match the real visitor because Django only sees Cloudflare's IP address.
|
|
167
|
+
|
|
168
|
+
The middleware uses Cloudflare's IP ranges to verify that the connecting IP address belongs to Cloudflare before trusting `CF-Connecting-IP`. It never trusts `X-Forwarded-For` and ignores spoofed `CF-Connecting-IP` headers from other sources.
|
|
169
|
+
|
|
170
|
+
If Cloudflare sits in front of Nginx or another reverse proxy, configure `IP_RESTRICT_TRUSTED_PROXY_IPS` first. The middleware verifies the Cloudflare IP received through that trusted proxy before reading `CF-Connecting-IP`.
|
|
171
|
+
|
|
172
|
+
To use the Cloudflare IP update command, add `django_ip_path_restrict` to `INSTALLED_APPS` in `settings.py`:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
INSTALLED_APPS = [
|
|
176
|
+
# Existing apps...
|
|
177
|
+
'django_ip_path_restrict',
|
|
178
|
+
]
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Adding the package to `INSTALLED_APPS` is not required if you only use the middleware.
|
|
182
|
+
|
|
183
|
+
### Configure the cache file
|
|
184
|
+
|
|
185
|
+
Set a location where the package can save Cloudflare's IP ranges. This should be outside publicly accessible directories such as `static` and `media`.
|
|
186
|
+
|
|
187
|
+
If the directory doesn't exist, the package will create it automatically.
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
CLOUDFLARE_IPS_FILE = BASE_DIR / 'private' / 'cloudflare_ips.json'
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Download Cloudflare IP ranges
|
|
194
|
+
|
|
195
|
+
The package does not include Cloudflare's IP ranges. Run the following command after installation to download them:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
python manage.py update_cloudflare_ips
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Schedule daily updates
|
|
202
|
+
|
|
203
|
+
You can use a cron job to update Cloudflare's IP ranges automatically once per day.
|
|
204
|
+
|
|
205
|
+
**Linux (crontab)**
|
|
206
|
+
|
|
207
|
+
```cron
|
|
208
|
+
0 4 * * * cd /home/USER/project && /home/USER/project/.venv/bin/python manage.py update_cloudflare_ips >> /home/USER/cloudflare_update.log 2>&1
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**cPanel**
|
|
212
|
+
|
|
213
|
+
In cPanel, go to **Advanced > Cron Jobs** and set the schedule to run daily at 4:00 AM.
|
|
214
|
+
|
|
215
|
+
Enter the following in the Command field:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
cd /home/USER/project && /home/USER/virtualenv/project/3.12/bin/python manage.py update_cloudflare_ips >> /home/USER/cloudflare_update.log 2>&1
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Replace the example paths with your actual project and virtual environment paths.
|
|
222
|
+
|
|
223
|
+
### Automatic updates (optional)
|
|
224
|
+
|
|
225
|
+
If you cannot schedule updates using a cron job, you can enable automatic updates when a protected URL is accessed:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
CLOUDFLARE_IPS_AUTO_UPDATE = True
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
With this setting enabled, the next request to a protected URL attempts to download Cloudflare's IP ranges if the cache file is missing, invalid, or more than 24 hours old. The request may take longer due to the download, which has a five-second timeout.
|
|
232
|
+
|
|
233
|
+
If the download fails and no valid cache file is available, requests relying on Cloudflare IP detection will be blocked.
|
|
234
|
+
|
|
235
|
+
### Share Cloudflare IP ranges between sites on the same server
|
|
236
|
+
|
|
237
|
+
Set each site's `CLOUDFLARE_IPS_FILE` to the same shared cache file, outside directories publicly accessible by your websites. Each site reads the file directly and automatically detects when it has been updated.
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
CLOUDFLARE_IPS_FILE = '/home/USER/private/cloudflare_ips.json'
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Run `update_cloudflare_ips` once per day from one site's virtual environment. It safely updates the shared file, preventing other sites from reading incomplete data.
|
|
244
|
+
|
|
245
|
+
## Author
|
|
246
|
+
|
|
247
|
+
Created by [Peter Stavrou](https://peterstavrou.com/).
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# django-ip-path-restrict
|
|
2
|
+
|
|
3
|
+
Restrict access to specific URLs so that only users with whitelisted IP addresses can access them.
|
|
4
|
+
|
|
5
|
+
Supports IPv4 and IPv6 addresses, IP ranges, and optional Cloudflare support.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python 3.11+
|
|
10
|
+
- Django 5.2+
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install django-ip-path-restrict
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
In the `settings.py` file, add the middleware near the top of `MIDDLEWARE`, preferably immediately after `SecurityMiddleware`:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
MIDDLEWARE = [
|
|
22
|
+
'django.middleware.security.SecurityMiddleware',
|
|
23
|
+
'django_ip_path_restrict.middleware.RestrictPathsByIPMiddleware',
|
|
24
|
+
# Other middleware...
|
|
25
|
+
]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Placing the middleware near the top allows unauthorised requests to be blocked early, before other middleware processes them.
|
|
29
|
+
|
|
30
|
+
## Configuration
|
|
31
|
+
|
|
32
|
+
Add `PROTECTED_PATHS` to `settings.py` to specify which URLs to restrict based on IP addresses.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
PROTECTED_PATHS = [
|
|
36
|
+
'/admin/*', # * matches /admin/ and all paths under it
|
|
37
|
+
'/login/', # Only matches /login/
|
|
38
|
+
]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Add `ALLOWED_IPS` to specify which IP addresses are allowed to access the protected URLs.
|
|
42
|
+
|
|
43
|
+
IPv4, IPv6 and CIDR IP ranges are supported:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
ALLOWED_IPS = [
|
|
47
|
+
'203.0.113.10', # IPv4 address
|
|
48
|
+
'2001:db8::10', # IPv6 address
|
|
49
|
+
'198.51.100.0/24', # IPv4 address range
|
|
50
|
+
]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Replace these example IP addresses with the addresses you want to allow.
|
|
54
|
+
|
|
55
|
+
Users who try to access protected URLs from an IP address that is not in `ALLOWED_IPS` will receive a 404 Not Found response.
|
|
56
|
+
|
|
57
|
+
All other URLs remain accessible as normal.
|
|
58
|
+
|
|
59
|
+
If your project already uses django-environ or another settings library, you can load `ALLOWED_IPS` from an environment variable instead.
|
|
60
|
+
|
|
61
|
+
### Local development
|
|
62
|
+
|
|
63
|
+
IP restrictions apply regardless of `DEBUG`. To access protected URLs locally, add the localhost IP addresses to `ALLOWED_IPS` in your development settings:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
ALLOWED_IPS = [
|
|
67
|
+
'127.0.0.1', # IPv4 localhost
|
|
68
|
+
'::1', # IPv6 localhost
|
|
69
|
+
]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Keep these addresses in your development settings rather than adding them to your production allowlist.
|
|
73
|
+
|
|
74
|
+
## Path matching
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
| Pattern | Paths matched |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `/admin` | `/admin` only |
|
|
80
|
+
| `/admin/` | `/admin/` only |
|
|
81
|
+
| `/admin/*` | `/admin/` and all paths under it, but not `/admin` |
|
|
82
|
+
| `/admin*` | All paths starting with `/admin`, including `/administration` |
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
For a standard Django admin installation, use `/admin/*`.
|
|
86
|
+
|
|
87
|
+
The only supported wildcard is `*`, which must appear at the end of a path.
|
|
88
|
+
|
|
89
|
+
## Use with a reverse proxy
|
|
90
|
+
|
|
91
|
+
When Nginx, Apache, LiteSpeed, or another reverse proxy sits in front of Django, `REMOTE_ADDR` may contain the proxy's IP address instead of the visitor's IP address.
|
|
92
|
+
|
|
93
|
+
Add the IP addresses of trusted reverse proxies to `IP_RESTRICT_TRUSTED_PROXY_IPS` in `settings.py`:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
IP_RESTRICT_TRUSTED_PROXY_IPS = [
|
|
97
|
+
'127.0.0.1', # IPv4 localhost
|
|
98
|
+
'::1', # IPv6 localhost
|
|
99
|
+
]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
These addresses are for reverse proxies running on the same server as Django. If your proxy runs on another server, use its IP address instead.
|
|
103
|
+
|
|
104
|
+
The middleware only trusts `X-Real-IP` when `REMOTE_ADDR` belongs to a trusted proxy. Otherwise, the header is ignored. If a trusted proxy does not provide a valid `X-Real-IP`, access to protected URLs is denied.
|
|
105
|
+
|
|
106
|
+
For Nginx, add the following line to the location block that forwards requests to Django:
|
|
107
|
+
|
|
108
|
+
```nginx
|
|
109
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Do not add `0.0.0.0/0` or `::/0` to `IP_RESTRICT_TRUSTED_PROXY_IPS`. Ensure the Django application server is not directly accessible from untrusted networks.
|
|
113
|
+
|
|
114
|
+
This feature supports TCP-connected proxies, not Unix sockets.
|
|
115
|
+
|
|
116
|
+
## Use with Cloudflare
|
|
117
|
+
|
|
118
|
+
Without Cloudflare or another reverse proxy, Django normally receives the visitor's IP address directly.
|
|
119
|
+
|
|
120
|
+
When your domain's DNS record is set to **Proxied** (orange cloud) in Cloudflare, your server receives a Cloudflare IP address instead of the visitor's IP address.
|
|
121
|
+
|
|
122
|
+
Cloudflare includes the visitor's original IP address in the `CF-Connecting-IP` request header.
|
|
123
|
+
|
|
124
|
+
Without the Cloudflare configuration, `ALLOWED_IPS` cannot match the real visitor because Django only sees Cloudflare's IP address.
|
|
125
|
+
|
|
126
|
+
The middleware uses Cloudflare's IP ranges to verify that the connecting IP address belongs to Cloudflare before trusting `CF-Connecting-IP`. It never trusts `X-Forwarded-For` and ignores spoofed `CF-Connecting-IP` headers from other sources.
|
|
127
|
+
|
|
128
|
+
If Cloudflare sits in front of Nginx or another reverse proxy, configure `IP_RESTRICT_TRUSTED_PROXY_IPS` first. The middleware verifies the Cloudflare IP received through that trusted proxy before reading `CF-Connecting-IP`.
|
|
129
|
+
|
|
130
|
+
To use the Cloudflare IP update command, add `django_ip_path_restrict` to `INSTALLED_APPS` in `settings.py`:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
INSTALLED_APPS = [
|
|
134
|
+
# Existing apps...
|
|
135
|
+
'django_ip_path_restrict',
|
|
136
|
+
]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Adding the package to `INSTALLED_APPS` is not required if you only use the middleware.
|
|
140
|
+
|
|
141
|
+
### Configure the cache file
|
|
142
|
+
|
|
143
|
+
Set a location where the package can save Cloudflare's IP ranges. This should be outside publicly accessible directories such as `static` and `media`.
|
|
144
|
+
|
|
145
|
+
If the directory doesn't exist, the package will create it automatically.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
CLOUDFLARE_IPS_FILE = BASE_DIR / 'private' / 'cloudflare_ips.json'
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Download Cloudflare IP ranges
|
|
152
|
+
|
|
153
|
+
The package does not include Cloudflare's IP ranges. Run the following command after installation to download them:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
python manage.py update_cloudflare_ips
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Schedule daily updates
|
|
160
|
+
|
|
161
|
+
You can use a cron job to update Cloudflare's IP ranges automatically once per day.
|
|
162
|
+
|
|
163
|
+
**Linux (crontab)**
|
|
164
|
+
|
|
165
|
+
```cron
|
|
166
|
+
0 4 * * * cd /home/USER/project && /home/USER/project/.venv/bin/python manage.py update_cloudflare_ips >> /home/USER/cloudflare_update.log 2>&1
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**cPanel**
|
|
170
|
+
|
|
171
|
+
In cPanel, go to **Advanced > Cron Jobs** and set the schedule to run daily at 4:00 AM.
|
|
172
|
+
|
|
173
|
+
Enter the following in the Command field:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
cd /home/USER/project && /home/USER/virtualenv/project/3.12/bin/python manage.py update_cloudflare_ips >> /home/USER/cloudflare_update.log 2>&1
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Replace the example paths with your actual project and virtual environment paths.
|
|
180
|
+
|
|
181
|
+
### Automatic updates (optional)
|
|
182
|
+
|
|
183
|
+
If you cannot schedule updates using a cron job, you can enable automatic updates when a protected URL is accessed:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
CLOUDFLARE_IPS_AUTO_UPDATE = True
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
With this setting enabled, the next request to a protected URL attempts to download Cloudflare's IP ranges if the cache file is missing, invalid, or more than 24 hours old. The request may take longer due to the download, which has a five-second timeout.
|
|
190
|
+
|
|
191
|
+
If the download fails and no valid cache file is available, requests relying on Cloudflare IP detection will be blocked.
|
|
192
|
+
|
|
193
|
+
### Share Cloudflare IP ranges between sites on the same server
|
|
194
|
+
|
|
195
|
+
Set each site's `CLOUDFLARE_IPS_FILE` to the same shared cache file, outside directories publicly accessible by your websites. Each site reads the file directly and automatically detects when it has been updated.
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
CLOUDFLARE_IPS_FILE = '/home/USER/private/cloudflare_ips.json'
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Run `update_cloudflare_ips` once per day from one site's virtual environment. It safely updates the shared file, preventing other sites from reading incomplete data.
|
|
202
|
+
|
|
203
|
+
## Author
|
|
204
|
+
|
|
205
|
+
Created by [Peter Stavrou](https://peterstavrou.com/).
|
|
File without changes
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Cloudflare IP range caching and on-request updates."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from ipaddress import ip_network
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from tempfile import NamedTemporaryFile
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
API_URL = 'https://api.cloudflare.com/client/v4/ips'
|
|
12
|
+
UPDATE_INTERVAL_SECONDS = 86400 # Default: 24 hours
|
|
13
|
+
IP_VERSIONS = {
|
|
14
|
+
'ipv4_cidrs': 4,
|
|
15
|
+
'ipv6_cidrs': 6,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_ip_ranges(ip_range_data):
|
|
20
|
+
"""Validate IPv4 and IPv6 ranges and normalize them for storage."""
|
|
21
|
+
if not isinstance(ip_range_data, dict):
|
|
22
|
+
raise ValueError('Expected a dictionary containing Cloudflare IP ranges')
|
|
23
|
+
|
|
24
|
+
validated_ip_ranges = {}
|
|
25
|
+
|
|
26
|
+
for ip_range_key, expected_ip_version in IP_VERSIONS.items():
|
|
27
|
+
ip_ranges = ip_range_data.get(ip_range_key)
|
|
28
|
+
|
|
29
|
+
if not isinstance(ip_ranges, list) or not ip_ranges:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f'Cloudflare response has no valid {ip_range_key}'
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
ip_networks = [
|
|
35
|
+
ip_network(ip_range, strict=True)
|
|
36
|
+
for ip_range in ip_ranges
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
if any(
|
|
40
|
+
network.version != expected_ip_version
|
|
41
|
+
for network in ip_networks
|
|
42
|
+
):
|
|
43
|
+
raise ValueError(f'Invalid address family in {ip_range_key}')
|
|
44
|
+
|
|
45
|
+
validated_ip_ranges[ip_range_key] = [str(network) for network in ip_networks]
|
|
46
|
+
|
|
47
|
+
return validated_ip_ranges
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fetch_cloudflare_ranges():
|
|
51
|
+
"""Fetch IP ranges from Cloudflare's public API."""
|
|
52
|
+
request_headers = {'User-Agent': 'django-ip-path-restrict/1.0.0'}
|
|
53
|
+
|
|
54
|
+
with urlopen(
|
|
55
|
+
Request(API_URL, headers=request_headers), timeout=5
|
|
56
|
+
) as api_response:
|
|
57
|
+
api_response_data = json.load(api_response)
|
|
58
|
+
|
|
59
|
+
if not api_response_data.get('success'):
|
|
60
|
+
raise ValueError('Cloudflare API returned an unsuccessful response')
|
|
61
|
+
|
|
62
|
+
return validate_ip_ranges(api_response_data.get('result'))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def save_cloudflare_ranges(cache_file_path, ip_ranges):
|
|
66
|
+
"""Save validated IP ranges without leaving an incomplete cache file."""
|
|
67
|
+
validated_ip_ranges = validate_ip_ranges(ip_ranges)
|
|
68
|
+
cache_file_path = Path(cache_file_path)
|
|
69
|
+
cache_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
temporary_file_path = None
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
with NamedTemporaryFile(
|
|
74
|
+
mode='w',
|
|
75
|
+
encoding='utf-8',
|
|
76
|
+
dir=cache_file_path.parent,
|
|
77
|
+
prefix='.cloudflare_ips_',
|
|
78
|
+
suffix='.tmp',
|
|
79
|
+
delete=False,
|
|
80
|
+
) as temporary_file:
|
|
81
|
+
temporary_file_path = Path(temporary_file.name)
|
|
82
|
+
json.dump(validated_ip_ranges, temporary_file, indent=2)
|
|
83
|
+
temporary_file.write('\n')
|
|
84
|
+
|
|
85
|
+
os.replace(temporary_file_path, cache_file_path)
|
|
86
|
+
finally:
|
|
87
|
+
if temporary_file_path is not None:
|
|
88
|
+
temporary_file_path.unlink(missing_ok=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def download_and_save(cache_file_path):
|
|
92
|
+
"""Download Cloudflare ranges and safely replace the cache file."""
|
|
93
|
+
cloudflare_ip_ranges = fetch_cloudflare_ranges()
|
|
94
|
+
save_cloudflare_ranges(cache_file_path, cloudflare_ip_ranges)
|
|
95
|
+
return (
|
|
96
|
+
len(cloudflare_ip_ranges['ipv4_cidrs'])
|
|
97
|
+
+ len(cloudflare_ip_ranges['ipv6_cidrs'])
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CloudflareIPStore:
|
|
102
|
+
"""Load cached Cloudflare ranges and refresh them once every 24 hours."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, cache_file_path):
|
|
105
|
+
self.cache_file_path = Path(cache_file_path)
|
|
106
|
+
self._cached_networks = ()
|
|
107
|
+
self._cache_file_modified_time_nanoseconds = None
|
|
108
|
+
self._last_update_attempt = 0
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _load_file(cache_file_path):
|
|
112
|
+
with Path(cache_file_path).open(encoding='utf-8') as cache_file:
|
|
113
|
+
return validate_ip_ranges(json.load(cache_file))
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _to_networks(ip_ranges):
|
|
117
|
+
ip_range_strings = ip_ranges['ipv4_cidrs'] + ip_ranges['ipv6_cidrs']
|
|
118
|
+
return tuple(ip_network(ip_range) for ip_range in ip_range_strings)
|
|
119
|
+
|
|
120
|
+
def get_networks(self):
|
|
121
|
+
"""Reload a changed cache file and fail closed when it is unavailable."""
|
|
122
|
+
try:
|
|
123
|
+
cache_file_modified_time_nanoseconds = self.cache_file_path.stat().st_mtime_ns
|
|
124
|
+
except OSError:
|
|
125
|
+
self._cached_networks = ()
|
|
126
|
+
self._cache_file_modified_time_nanoseconds = None
|
|
127
|
+
return self._cached_networks
|
|
128
|
+
|
|
129
|
+
if cache_file_modified_time_nanoseconds != self._cache_file_modified_time_nanoseconds:
|
|
130
|
+
try:
|
|
131
|
+
cached_ip_ranges = self._load_file(self.cache_file_path)
|
|
132
|
+
except (OSError, TypeError, ValueError):
|
|
133
|
+
self._cached_networks = ()
|
|
134
|
+
else:
|
|
135
|
+
self._cached_networks = self._to_networks(cached_ip_ranges)
|
|
136
|
+
|
|
137
|
+
self._cache_file_modified_time_nanoseconds = cache_file_modified_time_nanoseconds
|
|
138
|
+
|
|
139
|
+
return self._cached_networks
|
|
140
|
+
|
|
141
|
+
def refresh_if_stale(self):
|
|
142
|
+
"""Refresh a missing, invalid, or 24-hour-old cache on the next request."""
|
|
143
|
+
cached_networks = self.get_networks()
|
|
144
|
+
try:
|
|
145
|
+
cache_age = time.time() - self.cache_file_path.stat().st_mtime
|
|
146
|
+
except OSError:
|
|
147
|
+
cache_age = UPDATE_INTERVAL_SECONDS
|
|
148
|
+
|
|
149
|
+
if cached_networks and cache_age < UPDATE_INTERVAL_SECONDS:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
current_time = time.time()
|
|
153
|
+
if current_time - self._last_update_attempt < UPDATE_INTERVAL_SECONDS:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
self._last_update_attempt = current_time
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
download_and_save(self.cache_file_path)
|
|
160
|
+
except Exception:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
self.get_networks()
|
|
File without changes
|
|
File without changes
|
django_ip_path_restrict-1.0.0/django_ip_path_restrict/management/commands/update_cloudflare_ips.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Manually refresh the Cloudflare IP range cache."""
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
5
|
+
|
|
6
|
+
from ...cloudflare import download_and_save
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Command(BaseCommand):
|
|
10
|
+
help = 'Download Cloudflare IP ranges to CLOUDFLARE_IPS_FILE.'
|
|
11
|
+
|
|
12
|
+
def handle(self, *args, **options):
|
|
13
|
+
cache_file_path = getattr(settings, 'CLOUDFLARE_IPS_FILE', None)
|
|
14
|
+
if not cache_file_path:
|
|
15
|
+
raise CommandError('Set CLOUDFLARE_IPS_FILE in Django settings first.')
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
total_range_count = download_and_save(cache_file_path)
|
|
19
|
+
except Exception as update_error:
|
|
20
|
+
raise CommandError(
|
|
21
|
+
f'Cloudflare IP update failed: {update_error}'
|
|
22
|
+
) from update_error
|
|
23
|
+
|
|
24
|
+
self.stdout.write(
|
|
25
|
+
self.style.SUCCESS(
|
|
26
|
+
f'Updated {total_range_count} Cloudflare IP ranges in '
|
|
27
|
+
f'{cache_file_path}'
|
|
28
|
+
)
|
|
29
|
+
)
|