Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions datanar/core/middleware.py
Original file line number Diff line number Diff line change
@@ -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
127 changes: 127 additions & 0 deletions datanar/core/tests/test_real_ip.py
Original file line number Diff line number Diff line change
@@ -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],
"Лимит одного клиента задел другого",
)
13 changes: 12 additions & 1 deletion datanar/datanar/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -81,6 +91,7 @@
]

MIDDLEWARE = [
"core.middleware.RealIPMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
Expand Down
6 changes: 4 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/env-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 используется в проекте,
Expand Down
33 changes: 0 additions & 33 deletions for_docker/nginx-confs/datanar_local.conf

This file was deleted.

40 changes: 0 additions & 40 deletions for_docker/nginx-confs/datanar_prod.conf

This file was deleted.

30 changes: 30 additions & 0 deletions for_docker/nginx-confs/include/datanar.locations.conf
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions for_docker/nginx-confs/vhost/datanar.local.conf
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 14 additions & 0 deletions for_docker/nginx-confs/vhost/datanar.prod.conf
Original file line number Diff line number Diff line change
@@ -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;
}
Loading