Trust X-Forwarded-For only from ArvanCloud edges
nginx on the server also answers on its own IP. A direct request with a fresh X-Forwarded-For each time got a new throttle identity every time and skipped every per-IP limit (OTP request/login, admin login): reproduced on the server 2026-10-13, 65 of 65 health requests with rotating XFF got 200. TrustedProxyMiddleware reads X-Real-IP (set by nginx from the TCP peer) and, when the peer is not in TRUSTED_PROXY_NETWORKS (default: ArvanCloud's published list), replaces XFF with the peer address. Logs a WARNING once an hour per untrusted peer that sent a proxied XFF, so a new Arvan range shows up. Also ignore the parallel test DB clones (.test-db_N.sqlite3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,7 +19,7 @@ staticfiles/
|
|||||||
.deploy-backups/
|
.deploy-backups/
|
||||||
|
|
||||||
# دیتابیسِ فایلیِ تستها (config/settings.py → DATABASES.TEST)
|
# دیتابیسِ فایلیِ تستها (config/settings.py → DATABASES.TEST)
|
||||||
.test-db.sqlite3*
|
.test-db*.sqlite3*
|
||||||
tools/fake_openai_requests.jsonl
|
tools/fake_openai_requests.jsonl
|
||||||
.pg_password
|
.pg_password
|
||||||
.redis_password
|
.redis_password
|
||||||
|
|||||||
@@ -91,6 +91,52 @@ class APIRequestLoggingMiddleware:
|
|||||||
logger.exception('api_request logging failed')
|
logger.exception('api_request logging failed')
|
||||||
|
|
||||||
|
|
||||||
|
class TrustedProxyMiddleware:
|
||||||
|
"""
|
||||||
|
X-Forwarded-For فقط وقتی پذیرفته میشود که درخواست واقعاً از ArvanCloud آمده باشد.
|
||||||
|
|
||||||
|
NUM_PROXIES=2 فرض میکند همیشه آروان و nginx جلوی ما هستند؛ ولی nginxِ این ماشین روی IPِ خودش
|
||||||
|
(پورت ۸۰ و ۴۴۳) هم جواب میدهد. کسی که مستقیم به IP بزند و در هر درخواست یک XFFِ تازه بفرستد،
|
||||||
|
برای DRF هر بار «کاربرِ تازه» بود و از همهی سقفهای IP-محور (درخواستِ کد، حدسِ کد، ورودِ ادمین)
|
||||||
|
رد میشد (۲۰۲۶-۱۰-۱۳ روی سرور بازتولید شد: ۶۵ از ۶۵ درخواستِ health با XFFِ متفاوت ۲۰۰).
|
||||||
|
|
||||||
|
X-Real-IP را nginx خودش از آدرسِ TCP میگذارد (`proxy_params`ِ سایتِ `mathapp`؛ مقدارِ کلاینت را
|
||||||
|
بازنویسی میکند)، پس جعلپذیر نیست. اگر آن آدرس در TRUSTED_PROXY_NETWORKS نیست، XFF دور ریخته و
|
||||||
|
خودِ آن آدرس IPِ کاربر میشود. بی X-Real-IP (runserverِ محلی، تستها) دست نمیزند.
|
||||||
|
سنجیده نشد: رنجهای IPv6ِ آروان (فهرستِ آروان در این تاریخ فقط IPv4 داشت).
|
||||||
|
"""
|
||||||
|
|
||||||
|
WARN_EVERY_SECONDS = 60 * 60
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
from django.conf import settings
|
||||||
|
self.get_response = get_response
|
||||||
|
self.networks = settings.TRUSTED_PROXY_NETWORKS
|
||||||
|
|
||||||
|
def _trusted(self, peer: str) -> bool:
|
||||||
|
import ipaddress
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(peer)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(address in network for network in self.networks)
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
peer = request.META.get('HTTP_X_REAL_IP', '').strip()
|
||||||
|
if peer and not self._trusted(peer):
|
||||||
|
forwarded = request.META.get('HTTP_X_FORWARDED_FOR', '')
|
||||||
|
# XFF با دو مقدار یعنی پراکسیِ دیگری جلوی nginx بوده: یا کسی XFF جعل کرده، یا آروان رنجِ
|
||||||
|
# تازهای گرفته که در فهرست نیست. دومی همهی کاربرانِ آن edge را یک IP میکند و سقفها برایشان
|
||||||
|
# زود پر میشود؛ پس هر peer ساعتی یک بار WARNING میگیرد تا در لاگ دیده شود.
|
||||||
|
if forwarded.count(',') >= 1:
|
||||||
|
from django.core.cache import cache
|
||||||
|
if cache.add(f'untrusted_proxy_warned:{peer}', 1, self.WARN_EVERY_SECONDS):
|
||||||
|
logger.warning('X-Forwarded-For from a peer outside TRUSTED_PROXY_NETWORKS ignored: '
|
||||||
|
'peer=%s (a new ArvanCloud range would also look like this)', peer)
|
||||||
|
request.META['HTTP_X_FORWARDED_FOR'] = peer
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
|
|
||||||
class APINoStoreMiddleware:
|
class APINoStoreMiddleware:
|
||||||
"""
|
"""
|
||||||
هر پاسخِ `/api/` هدرِ `Cache-Control: no-store` میگیرد، مگر خودِ view
|
هر پاسخِ `/api/` هدرِ `Cache-Control: no-store` میگیرد، مگر خودِ view
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ For the full list of settings and their values, see
|
|||||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -265,7 +266,25 @@ INSTALLED_APPS = [
|
|||||||
'rag',
|
'rag',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# شبکههایی که X-Forwarded-Forشان پذیرفته میشود (config.middleware.TrustedProxyMiddleware).
|
||||||
|
# پیشفرض: فهرستِ edgeهای ArvanCloud از https://www.arvancloud.ir/fa/ips.txt (۲۰۲۶-۱۰-۱۳). اگر آروان رنجِ
|
||||||
|
# تازه بگیرد و اینجا نباشد، کاربرانِ پشتِ آن edge یک IP دیده میشوند و لاگ WARNINGِ
|
||||||
|
# «outside TRUSTED_PROXY_NETWORKS» میدهد؛ آن وقت فهرست را از همان آدرس تازه کنید (یا در .env بگذارید).
|
||||||
|
# مقدارِ نامعتبر بالا آمدن را میشکند، نه اینکه بیصدا نادیده گرفته شود.
|
||||||
|
_ARVAN_EDGE_NETWORKS = (
|
||||||
|
'185.143.232.0/22,188.229.116.16/30,94.101.182.0/27,2.144.3.128/28,37.32.16.0/27,37.32.17.0/27,'
|
||||||
|
'37.32.18.0/27,37.32.19.0/27,185.215.232.0/22,178.131.120.48/28,94.101.183.0/28,78.157.36.112/28,'
|
||||||
|
'95.38.61.80/28,193.24.119.0/29'
|
||||||
|
)
|
||||||
|
TRUSTED_PROXY_NETWORKS = [
|
||||||
|
ipaddress.ip_network(cidr.strip())
|
||||||
|
for cidr in os.environ.get('TRUSTED_PROXY_NETWORKS', _ARVAN_EDGE_NETWORKS).split(',')
|
||||||
|
if cidr.strip()
|
||||||
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
# پیش از هر چیزی که IPِ کاربر را میخواند (سقفِ ورودِ ادمین، throttleهای DRF).
|
||||||
|
'config.middleware.TrustedProxyMiddleware',
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
# ⚠️ CorsMiddleware باید تا حدِ ممکن بالا و حتماً قبل از CommonMiddleware
|
# ⚠️ CorsMiddleware باید تا حدِ ممکن بالا و حتماً قبل از CommonMiddleware
|
||||||
# باشد، وگرنه پاسخهایی که زودتر برمیگردند (ریدایرکتِ APPEND_SLASH، خطای
|
# باشد، وگرنه پاسخهایی که زودتر برمیگردند (ریدایرکتِ APPEND_SLASH، خطای
|
||||||
|
|||||||
73
config/tests_trusted_proxy.py
Normal file
73
config/tests_trusted_proxy.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
X-Forwarded-For فقط از ArvanCloud پذیرفته میشود (config.middleware.TrustedProxyMiddleware).
|
||||||
|
|
||||||
|
بازتولیدِ ۲۰۲۶-۱۰-۱۳ روی سرور: درخواستِ مستقیم به IPِ سرور با XFFِ تازه در هر بار، همهی سقفهای IP-محور را
|
||||||
|
دور میزد. X-Real-IP همان چیزی است که nginx از آدرسِ TCP میگذارد.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.test import RequestFactory, TestCase, override_settings
|
||||||
|
from rest_framework.throttling import BaseThrottle
|
||||||
|
|
||||||
|
from config.middleware import TrustedProxyMiddleware
|
||||||
|
|
||||||
|
ARVAN_EDGE = '37.32.19.8'
|
||||||
|
|
||||||
|
|
||||||
|
def _ident_after_middleware(**meta):
|
||||||
|
request = RequestFactory().get('/api/health', **meta)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def view(req):
|
||||||
|
seen['ident'] = BaseThrottle().get_ident(req)
|
||||||
|
return None
|
||||||
|
|
||||||
|
TrustedProxyMiddleware(view)(request)
|
||||||
|
return seen['ident']
|
||||||
|
|
||||||
|
|
||||||
|
class TrustedProxyIdentTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def test_request_through_arvan_uses_the_client_address(self):
|
||||||
|
ident = _ident_after_middleware(
|
||||||
|
HTTP_X_REAL_IP=ARVAN_EDGE, HTTP_X_FORWARDED_FOR=f'5.5.5.5, {ARVAN_EDGE}',
|
||||||
|
)
|
||||||
|
self.assertEqual(ident, '5.5.5.5')
|
||||||
|
|
||||||
|
def test_direct_hit_ignores_a_forged_forwarded_for(self):
|
||||||
|
for forged in ('1.2.3.4', '9.9.9.9, 1.2.3.4'):
|
||||||
|
ident = _ident_after_middleware(
|
||||||
|
HTTP_X_REAL_IP='203.0.113.7', HTTP_X_FORWARDED_FOR=f'{forged}, 203.0.113.7',
|
||||||
|
)
|
||||||
|
self.assertEqual(ident, '203.0.113.7')
|
||||||
|
|
||||||
|
def test_direct_hit_is_logged_once_per_peer(self):
|
||||||
|
with self.assertLogs('config.middleware', level='WARNING') as logs:
|
||||||
|
for _ in range(3):
|
||||||
|
_ident_after_middleware(
|
||||||
|
HTTP_X_REAL_IP='203.0.113.9', HTTP_X_FORWARDED_FOR='1.1.1.1, 203.0.113.9',
|
||||||
|
)
|
||||||
|
self.assertEqual(len(logs.records), 1)
|
||||||
|
|
||||||
|
def test_without_nginx_header_nothing_changes(self):
|
||||||
|
# runserverِ محلی: nginx نیست، پس X-Real-IP هم نیست.
|
||||||
|
ident = _ident_after_middleware(REMOTE_ADDR='127.0.0.1')
|
||||||
|
self.assertEqual(ident, '127.0.0.1')
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ADMIN_LOGIN_MAX_ATTEMPTS=2)
|
||||||
|
class AdminLoginThrottleCannotBeDodgedTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def test_rotating_forwarded_for_on_a_direct_hit_still_hits_the_limit(self):
|
||||||
|
codes = [
|
||||||
|
self.client.post(
|
||||||
|
'/admin/login/', {'username': 'x', 'password': 'y'},
|
||||||
|
HTTP_X_REAL_IP='203.0.113.7', HTTP_X_FORWARDED_FOR=f'10.0.{i}.1, 203.0.113.7',
|
||||||
|
).status_code
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
self.assertEqual(codes[-1], 429)
|
||||||
Reference in New Issue
Block a user