From e3bddb37579e0789ab4171d326581b55becb108b Mon Sep 17 00:00:00 2001 From: Zareian Date: Tue, 13 Oct 2026 18:45:27 +0330 Subject: [PATCH] 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 --- .gitignore | 2 +- config/middleware.py | 46 ++++++++++++++++++++++ config/settings.py | 19 +++++++++ config/tests_trusted_proxy.py | 73 +++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 config/tests_trusted_proxy.py diff --git a/.gitignore b/.gitignore index 4656de7..058f40e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ staticfiles/ .deploy-backups/ # دیتابیسِ فایلیِ تست‌ها (config/settings.py → DATABASES.TEST) -.test-db.sqlite3* +.test-db*.sqlite3* tools/fake_openai_requests.jsonl .pg_password .redis_password diff --git a/config/middleware.py b/config/middleware.py index 1919967..744d85d 100644 --- a/config/middleware.py +++ b/config/middleware.py @@ -91,6 +91,52 @@ class APIRequestLoggingMiddleware: 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: """ هر پاسخِ `/api/` هدرِ `Cache-Control: no-store` می‌گیرد، مگر خودِ view diff --git a/config/settings.py b/config/settings.py index c288e9f..368480b 100644 --- a/config/settings.py +++ b/config/settings.py @@ -10,6 +10,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.2/ref/settings/ """ +import ipaddress import os import re from pathlib import Path @@ -265,7 +266,25 @@ INSTALLED_APPS = [ '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 = [ + # پیش از هر چیزی که IPِ کاربر را می‌خواند (سقفِ ورودِ ادمین، throttleهای DRF). + 'config.middleware.TrustedProxyMiddleware', 'django.middleware.security.SecurityMiddleware', # ⚠️ CorsMiddleware باید تا حدِ ممکن بالا و حتماً قبل از CommonMiddleware # باشد، وگرنه پاسخ‌هایی که زودتر برمی‌گردند (ریدایرکتِ APPEND_SLASH، خطای diff --git a/config/tests_trusted_proxy.py b/config/tests_trusted_proxy.py new file mode 100644 index 0000000..c214924 --- /dev/null +++ b/config/tests_trusted_proxy.py @@ -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)