diff --git a/.env.example b/.env.example index b3fdb74..f3479b9 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ DATANAR_MAIL_PORT=2525 DATANAR_MAIL_USER=webmaster@localhost DATANAR_MAIL_PASSWORD=this_very_secret_password_for_smtp_mail +DATANAR_TRUSTED_PROXIES=172.16.0.0/12,127.0.0.1/32 +DATANAR_REAL_IP_HEADER=HTTP_X_REAL_IP DATANAR_SITE_ID=1 DATANAR_SITE_DOMAIN=datanar.ru DATANAR_SITE_NAME=Datanar diff --git a/datanar/core/middleware.py b/datanar/core/middleware.py new file mode 100644 index 0000000..fcc1737 --- /dev/null +++ b/datanar/core/middleware.py @@ -0,0 +1,50 @@ +__all__ = ("RealIPMiddleware",) + +from collections.abc import Callable +from ipaddress import ip_address, ip_network + +from django.conf import settings +from django.http import HttpRequest, HttpResponse + + +class RealIPMiddleware: + def __init__( + self, + get_response: Callable[[HttpRequest], HttpResponse], + ) -> None: + self.get_response = get_response + self.trusted = [ + ip_network(network) for network in settings.TRUSTED_PROXIES + ] + + def __call__(self, request: HttpRequest) -> HttpResponse: + client_ip = self.get_client_ip(request) + if client_ip is not None: + request.META["REMOTE_ADDR"] = client_ip + + return self.get_response(request) + + def get_client_ip(self, request: HttpRequest) -> str | None: + if not self.is_trusted(request.META.get("REMOTE_ADDR")): + return None + + header = request.META.get(settings.REAL_IP_HEADER, "") + return self.parse(header.split(",")[0].strip()) + + def is_trusted(self, value: str | None) -> bool: + parsed = self.parse(value) + if parsed is None: + return False + + address = ip_address(parsed) + return any(address in network for network in self.trusted) + + @staticmethod + def parse(value: str | None) -> str | None: + if not value: + return None + + try: + return str(ip_address(value)) + except ValueError: + return None diff --git a/datanar/core/tests/test_real_ip.py b/datanar/core/tests/test_real_ip.py new file mode 100644 index 0000000..ba60931 --- /dev/null +++ b/datanar/core/tests/test_real_ip.py @@ -0,0 +1,127 @@ +__all__ = () + +from http import HTTPStatus + +from django.core.cache import cache +from django.test import Client, override_settings, TestCase +from django.urls import reverse + + +@override_settings( + TRUSTED_PROXIES=["172.16.0.0/12"], + REAL_IP_HEADER="HTTP_X_REAL_IP", +) +class RealIPMiddlewareTest(TestCase): + def remote_addr(self, **extra: str) -> str: + response = Client().get(reverse("homepage:home"), **extra) + self.assertEqual(response.status_code, HTTPStatus.OK) + return response.wsgi_request.META["REMOTE_ADDR"] + + def test_replaced_behind_trusted_proxy(self) -> None: + self.assertEqual( + self.remote_addr( + REMOTE_ADDR="172.18.0.5", + HTTP_X_REAL_IP="203.0.113.7", + ), + "203.0.113.7", + "`REMOTE_ADDR` не заменён адресом клиента от доверенного прокси", + ) + + def test_kept_when_proxy_is_not_trusted(self) -> None: + self.assertEqual( + self.remote_addr( + REMOTE_ADDR="203.0.113.1", + HTTP_X_REAL_IP="198.51.100.9", + ), + "203.0.113.1", + "`REMOTE_ADDR` заменён по заголовку от недоверенного адреса", + ) + + def test_kept_without_header(self) -> None: + self.assertEqual( + self.remote_addr(REMOTE_ADDR="172.18.0.5"), + "172.18.0.5", + "`REMOTE_ADDR` изменён без заголовка с адресом клиента", + ) + + def test_broken_header_ignored(self) -> None: + self.assertEqual( + self.remote_addr( + REMOTE_ADDR="172.18.0.5", + HTTP_X_REAL_IP="not-an-ip", + ), + "172.18.0.5", + "`REMOTE_ADDR` заменён мусором из заголовка", + ) + + def test_first_address_of_list_taken(self) -> None: + self.assertEqual( + self.remote_addr( + REMOTE_ADDR="172.18.0.5", + HTTP_X_REAL_IP="203.0.113.7, 172.18.0.5", + ), + "203.0.113.7", + "Из списка адресов взят не первый", + ) + + @override_settings(TRUSTED_PROXIES=[]) + def test_disabled_by_empty_trusted_proxies(self) -> None: + self.assertEqual( + self.remote_addr( + REMOTE_ADDR="172.18.0.5", + HTTP_X_REAL_IP="203.0.113.7", + ), + "172.18.0.5", + "`REMOTE_ADDR` заменён при пустом `TRUSTED_PROXIES`", + ) + + +@override_settings( + TRUSTED_PROXIES=["172.16.0.0/12"], + ACCOUNT_RATE_LIMITS={"signup": "3/300s/ip"}, + PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"], +) +class SignupRateLimitTest(TestCase): + def setUp(self) -> None: + cache.clear() + + def signup(self, real_ip: str, count: int) -> list[int]: + codes = [] + for i in range(count): + name = f"user{real_ip.replace('.', '')}{i}" + response = Client().post( + reverse("users:signup"), + { + "username": name, + "email": f"{name}@email.com", + "password1": "some_password_123!", + "password2": "some_password_123!", + }, + REMOTE_ADDR="172.18.0.5", + HTTP_X_REAL_IP=real_ip, + ) + codes.append(response.status_code) + + return codes + + def test_limit_applies_per_client_ip(self) -> None: + self.assertEqual( + self.signup("203.0.113.7", 5), + [ + HTTPStatus.FOUND, + HTTPStatus.FOUND, + HTTPStatus.FOUND, + HTTPStatus.TOO_MANY_REQUESTS, + HTTPStatus.TOO_MANY_REQUESTS, + ], + "Лимит регистраций не сработал на адресе клиента", + ) + + def test_limit_does_not_leak_between_clients(self) -> None: + self.signup("203.0.113.7", 5) + + self.assertEqual( + self.signup("198.51.100.9", 2), + [HTTPStatus.FOUND, HTTPStatus.FOUND], + "Лимит одного клиента задел другого", + ) diff --git a/datanar/datanar/settings.py b/datanar/datanar/settings.py index 184c59c..daa91a7 100644 --- a/datanar/datanar/settings.py +++ b/datanar/datanar/settings.py @@ -7,7 +7,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent -VERSION = "2.6.4" +VERSION = "2.6.5" API_VERSION = "1.2.0" SECRET_KEY = config( @@ -54,6 +54,16 @@ CSRF_TRUSTED_ORIGINS = [f"https://{x}" for x in ALLOWED_HOSTS] CSRF_FAILURE_VIEW = "core.views.csrf_failure" +TRUSTED_PROXIES = config( + "DATANAR_TRUSTED_PROXIES", + default="172.16.0.0/12,127.0.0.1/32", + cast=lambda line: [x for x in line.split(",") if x], +) +REAL_IP_HEADER = config( + "DATANAR_REAL_IP_HEADER", + default="HTTP_X_REAL_IP", +) + INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", @@ -81,6 +91,7 @@ ] MIDDLEWARE = [ + "core.middleware.RealIPMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", diff --git a/docker-compose.yml b/docker-compose.yml index be096e3..560cb54 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,8 @@ services: - "80:80" - "443:443" volumes: - - ./for_docker/nginx-confs/datanar_prod.conf:/etc/nginx/user_conf.d/datanar.conf + - ./for_docker/nginx-confs/vhost/datanar.prod.conf:/etc/nginx/user_conf.d/datanar.conf + - ./for_docker/nginx-confs:/etc/nginx/projects/datanar:ro - ./for_docker/nginx-confs/robots.txt:/datanar/static/robots.txt - ./for_docker/nginx_certificates:/etc/letsencrypt - ./for_docker/static:/datanar/static @@ -60,7 +61,8 @@ services: ports: - "80:80" volumes: - - ./for_docker/nginx-confs/datanar_local.conf:/etc/nginx/conf.d/default.conf + - ./for_docker/nginx-confs/vhost/datanar.local.conf:/etc/nginx/conf.d/default.conf + - ./for_docker/nginx-confs:/etc/nginx/projects/datanar:ro - ./for_docker/nginx-confs/robots.txt:/datanar/static/robots.txt - ./for_docker/static:/datanar/static - ./for_docker/media:/datanar/media diff --git a/docs/env-file.md b/docs/env-file.md index 3855a86..fdc3d04 100644 --- a/docs/env-file.md +++ b/docs/env-file.md @@ -134,6 +134,21 @@ - **Пароль** суперпользователя с логином [`DATANAR_SUPERUSER_NAME`](#datanar_superuser_name) +## Обратный прокси +### DATANAR_TRUSTED_PROXIES +- Список сетей через запятую, из которых приложение доверяет заголовку + с адресом клиента. Запросы приходят от nginx, поэтому без этого Django + видит адрес прокси, а не посетителя, и всё, что считает по адресу - + в первую очередь лимиты allauth на регистрацию и вход - складывает + всех клиентов в один счётчик +- Пустое значение выключает подмену: адресом клиента будет считаться тот, + с которого пришёл запрос +### DATANAR_REAL_IP_HEADER +- Имя заголовка с адресом клиента в формате WSGI. По умолчанию + `HTTP_X_REAL_IP`, то есть заголовок `X-Real-IP`, который выставляет nginx. + Для `X-Forwarded-For` укажите `HTTP_X_FORWARDED_FOR` - берётся первый + адрес из списка + ## Конфигурация сайта ### DATANAR_SITE_ID - Этот параметр указывает, какой сайт из таблицы django_site используется в проекте, diff --git a/for_docker/nginx-confs/datanar_local.conf b/for_docker/nginx-confs/datanar_local.conf deleted file mode 100644 index b9ab6ad..0000000 --- a/for_docker/nginx-confs/datanar_local.conf +++ /dev/null @@ -1,33 +0,0 @@ -server { - listen 80 default_server reuseport; - listen [::]:80 default_server reuseport; - server_name _ localhost; - - location / { - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_headers_hash_max_size 1024; - proxy_headers_hash_bucket_size 128; - - proxy_pass http://django:8000; - } - - location = /favicon.ico { - alias /datanar/static/img/favicon.ico; - } - - location = /robots.txt { - alias /datanar/static/robots.txt; - } - - location /static/ { - root /datanar; - } - - location /media/ { - root /datanar; - } -} \ No newline at end of file diff --git a/for_docker/nginx-confs/datanar_prod.conf b/for_docker/nginx-confs/datanar_prod.conf deleted file mode 100644 index dc98741..0000000 --- a/for_docker/nginx-confs/datanar_prod.conf +++ /dev/null @@ -1,40 +0,0 @@ -server { - listen 443 ssl default_server reuseport; - listen [::]:443 ssl default_server reuseport; - server_name datanar.ru www.datanar.ru; - - ssl_certificate /etc/letsencrypt/live/datanar.ru/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/datanar.ru/privkey.pem; - ssl_trusted_certificate /etc/letsencrypt/live/datanar.ru/chain.pem; - ssl_dhparam /etc/letsencrypt/dhparams/dhparam.pem; - - http2 on; - - location / { - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_headers_hash_max_size 1024; - proxy_headers_hash_bucket_size 128; - - proxy_pass http://django:8000; - } - - location = /favicon.ico { - alias /datanar/static/img/favicon.ico; - } - - location = /robots.txt { - alias /datanar/static/robots.txt; - } - - location /static/ { - root /datanar; - } - - location /media/ { - root /datanar; - } -} \ No newline at end of file diff --git a/for_docker/nginx-confs/include/datanar.locations.conf b/for_docker/nginx-confs/include/datanar.locations.conf new file mode 100644 index 0000000..e525842 --- /dev/null +++ b/for_docker/nginx-confs/include/datanar.locations.conf @@ -0,0 +1,30 @@ +location / { + resolver 127.0.0.11 valid=10s ipv6=off; + set $datanar_app django; + + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_headers_hash_max_size 1024; + proxy_headers_hash_bucket_size 128; + + proxy_pass http://$datanar_app:8000; +} + +location = /favicon.ico { + alias /datanar/static/img/favicon.ico; +} + +location = /robots.txt { + alias /datanar/static/robots.txt; +} + +location /static/ { + root /datanar; +} + +location /media/ { + root /datanar; +} diff --git a/for_docker/nginx-confs/vhost/datanar.local.conf b/for_docker/nginx-confs/vhost/datanar.local.conf new file mode 100644 index 0000000..810ee44 --- /dev/null +++ b/for_docker/nginx-confs/vhost/datanar.local.conf @@ -0,0 +1,7 @@ +server { + listen 80 default_server reuseport; + listen [::]:80 default_server reuseport; + server_name _ localhost; + + include /etc/nginx/projects/datanar/include/datanar.locations.conf; +} diff --git a/for_docker/nginx-confs/vhost/datanar.prod.conf b/for_docker/nginx-confs/vhost/datanar.prod.conf new file mode 100644 index 0000000..039e966 --- /dev/null +++ b/for_docker/nginx-confs/vhost/datanar.prod.conf @@ -0,0 +1,14 @@ +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name datanar.ru www.datanar.ru; + + ssl_certificate /etc/letsencrypt/live/datanar.ru/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/datanar.ru/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/datanar.ru/chain.pem; + ssl_dhparam /etc/letsencrypt/dhparams/dhparam.pem; + + http2 on; + + include /etc/nginx/projects/datanar/include/datanar.locations.conf; +}