From c2f0ae0b8b8d6203c09c6a5b8d27118cee598e98 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 8 Jun 2026 11:25:02 +0300 Subject: [PATCH] fix: resolve webhook client IP behind proxies --- .env.example | 5 ++- README.md | 1 + backend/bot/utils/request_security.py | 35 ++++++++++----- backend/config/settings.py | 13 +++++- deploy/docker/caddy/Caddyfile | 3 ++ deploy/docker/nginx/remnawave-minishop.conf | 1 + deploy/examples/caddy/.env.example | 4 +- deploy/examples/caddy/Caddyfile | 3 ++ deploy/examples/caddy/docker-compose.yml | 2 +- deploy/examples/newt/.env.example | 4 +- deploy/examples/newt/docker-compose.yml | 2 +- deploy/examples/nginx/.env.example | 4 +- deploy/examples/nginx/docker-compose.yml | 2 +- deploy/examples/nginx/nginx.conf.template | 1 + docs/configuration/env-vars.md | 11 ++++- docs/configuration/security.md | 8 ++++ docs/features/payments.md | 6 +++ docs/getting-started/deployment.md | 10 +++++ scripts/install.sh | 2 +- tests/test_security.py | 49 ++++++++++++++++++++- tests/test_settings.py | 20 +++++++++ 21 files changed, 161 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 63920b6..0f1d331 100644 --- a/.env.example +++ b/.env.example @@ -71,8 +71,9 @@ WEB_SERVER_PORT=8080 FRONTEND_PORT=8082 # Reverse proxy IPs/CIDRs trusted for X-Forwarded-For. -# Keep loopback for local proxy; add your proxy network if needed. -TRUSTED_PROXIES=127.0.0.1,::1 +# Keep loopback and private network ranges so payment provider IP allowlists +# see the original webhook sender behind Docker/LAN/Kubernetes proxies. +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7 # ─── Anonymous install telemetry (opt-out) ────────────────────────────── # Once a day the worker sends a single anonymous "heartbeat" so the project diff --git a/README.md b/README.md index e1e3d8b..63281ad 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ docker compose logs -f backend worker frontend - `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты; - `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`; - `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave; +- `TRUSTED_PROXIES` - оставьте дефолт для Docker/Caddy/Nginx/Newt или укажите IP/CIDR своего reverse proxy, чтобы IP allowlist платежных webhook видел реального провайдера; - остальные настройки удобнее задать в Web App админке. В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке. diff --git a/backend/bot/utils/request_security.py b/backend/bot/utils/request_security.py index bba0bc5..8b2a3e9 100644 --- a/backend/bot/utils/request_security.py +++ b/backend/bot/utils/request_security.py @@ -34,12 +34,26 @@ def _parse_ip(value: Optional[str]) -> Optional[ipaddress._BaseAddress]: return None -def _last_forwarded_ip(header_value: str) -> Optional[str]: +def _forwarded_ips(header_value: str) -> list[ipaddress._BaseAddress]: candidates = [item.strip() for item in header_value.split(",") if item.strip()] - if not candidates: - return None - candidate = candidates[-1] - return candidate if _parse_ip(candidate) is not None else None + parsed: list[ipaddress._BaseAddress] = [] + for candidate in candidates: + parsed_ip = _parse_ip(candidate) + if parsed_ip is not None: + parsed.append(parsed_ip) + return parsed + + +def _forwarded_client_ip( + forwarded_ips: Sequence[ipaddress._BaseAddress], + trusted_networks: Sequence[ipaddress._BaseNetwork], +) -> Optional[str]: + for forwarded_ip in reversed(forwarded_ips): + if not any(forwarded_ip in network for network in trusted_networks): + return str(forwarded_ip) + if forwarded_ips: + return str(forwarded_ips[0]) + return None def request_client_ip( @@ -48,20 +62,21 @@ def request_client_ip( trusted_proxies: Optional[Sequence[str] | str] = None, ) -> Optional[str]: remote_ip = _parse_ip(request.remote or "") - forwarded_for = request.headers.get("X-Forwarded-For", "") + forwarded_ips = _forwarded_ips(request.headers.get("X-Forwarded-For", "")) - if remote_ip and forwarded_for: + if remote_ip and forwarded_ips: trusted_networks = parse_ip_entries(trusted_proxies) if any(remote_ip in network for network in trusted_networks): - forwarded_ip = _last_forwarded_ip(forwarded_for) + forwarded_ip = _forwarded_client_ip(forwarded_ips, trusted_networks) if forwarded_ip: return forwarded_ip if remote_ip: return str(remote_ip) - forwarded_ip = _last_forwarded_ip(forwarded_for) - return forwarded_ip + if forwarded_ips: + return str(forwarded_ips[-1]) + return None def ip_in_allowlist( diff --git a/backend/config/settings.py b/backend/config/settings.py index fc05dd6..19acc12 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -138,6 +138,17 @@ DEFAULT_DISPOSABLE_EMAIL_DOMAINS = "\n".join( ] ) +DEFAULT_TRUSTED_PROXIES = ",".join( + [ + "127.0.0.1", + "::1", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7", + ] +) + class DBSettings(BaseModel): user: str @@ -298,7 +309,7 @@ class Settings(BaseSettings): WEBHOOK_BASE_URL: Optional[str] = None TRUSTED_PROXIES: Optional[str] = Field( - default="127.0.0.1,::1", + default=DEFAULT_TRUSTED_PROXIES, description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.", # noqa: E501 ) diff --git a/deploy/docker/caddy/Caddyfile b/deploy/docker/caddy/Caddyfile index 53afd2d..ddd0820 100644 --- a/deploy/docker/caddy/Caddyfile +++ b/deploy/docker/caddy/Caddyfile @@ -2,11 +2,14 @@ app.example.com { encode zstd gzip + # Caddy sets X-Forwarded-For/Host/Proto for reverse_proxy by default. + # The backend uses X-Forwarded-For for payment provider IP allowlists. reverse_proxy backend:8080 } web.example.com { encode zstd gzip + # Keep forwarded headers for Web App URL generation and request logging. reverse_proxy frontend:80 } diff --git a/deploy/docker/nginx/remnawave-minishop.conf b/deploy/docker/nginx/remnawave-minishop.conf index 687f404..7f48e0d 100644 --- a/deploy/docker/nginx/remnawave-minishop.conf +++ b/deploy/docker/nginx/remnawave-minishop.conf @@ -72,6 +72,7 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + # Required for payment provider IP allowlists in webhook handlers. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/deploy/examples/caddy/.env.example b/deploy/examples/caddy/.env.example index 5d7fd3d..0ccb4a1 100644 --- a/deploy/examples/caddy/.env.example +++ b/deploy/examples/caddy/.env.example @@ -36,5 +36,5 @@ PANEL_API_URL=https://panel.example.com/api PANEL_API_KEY=change_me PANEL_WEBHOOK_SECRET=change_me -# Caddy and Docker network ranges that may set X-Forwarded-For. -TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12 +# Caddy and private Docker/LAN ranges that may set X-Forwarded-For. +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7 diff --git a/deploy/examples/caddy/Caddyfile b/deploy/examples/caddy/Caddyfile index edf2b96..ff17819 100644 --- a/deploy/examples/caddy/Caddyfile +++ b/deploy/examples/caddy/Caddyfile @@ -8,12 +8,15 @@ {$WEBHOOK_HOST} { encode zstd gzip + # Caddy sets X-Forwarded-For/Host/Proto for reverse_proxy by default. + # The backend uses X-Forwarded-For for payment provider IP allowlists. reverse_proxy backend:8080 } {$MINIAPP_HOST} { encode zstd gzip + # Keep forwarded headers for Web App URL generation and request logging. reverse_proxy frontend:80 } diff --git a/deploy/examples/caddy/docker-compose.yml b/deploy/examples/caddy/docker-compose.yml index 6321b0f..2ade806 100644 --- a/deploy/examples/caddy/docker-compose.yml +++ b/deploy/examples/caddy/docker-compose.yml @@ -64,7 +64,7 @@ services: environment: <<: *app_environment WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true} - TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7} volumes: - ./data:/app/data - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} diff --git a/deploy/examples/newt/.env.example b/deploy/examples/newt/.env.example index 4672a0b..9e2d75f 100644 --- a/deploy/examples/newt/.env.example +++ b/deploy/examples/newt/.env.example @@ -37,5 +37,5 @@ PANEL_API_URL=https://panel.example.com/api PANEL_API_KEY=change_me PANEL_WEBHOOK_SECRET=change_me -# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For. -TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12 +# Pangolin/Newt and private Docker/LAN ranges that may set X-Forwarded-For. +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7 diff --git a/deploy/examples/newt/docker-compose.yml b/deploy/examples/newt/docker-compose.yml index ad7c3d5..90a2e24 100644 --- a/deploy/examples/newt/docker-compose.yml +++ b/deploy/examples/newt/docker-compose.yml @@ -64,7 +64,7 @@ services: environment: <<: *app_environment WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true} - TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7} volumes: - ./data:/app/data - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} diff --git a/deploy/examples/nginx/.env.example b/deploy/examples/nginx/.env.example index c93a284..8f7b463 100644 --- a/deploy/examples/nginx/.env.example +++ b/deploy/examples/nginx/.env.example @@ -36,5 +36,5 @@ PANEL_API_URL=https://panel.example.com/api PANEL_API_KEY=change_me PANEL_WEBHOOK_SECRET=change_me -# Nginx and Docker network ranges that may set X-Forwarded-For. -TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12 +# Nginx and private Docker/LAN ranges that may set X-Forwarded-For. +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7 diff --git a/deploy/examples/nginx/docker-compose.yml b/deploy/examples/nginx/docker-compose.yml index 5a9f1d1..516a6a4 100644 --- a/deploy/examples/nginx/docker-compose.yml +++ b/deploy/examples/nginx/docker-compose.yml @@ -64,7 +64,7 @@ services: environment: <<: *app_environment WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true} - TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7} volumes: - ./data:/app/data - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} diff --git a/deploy/examples/nginx/nginx.conf.template b/deploy/examples/nginx/nginx.conf.template index 829cbe8..e5cb768 100644 --- a/deploy/examples/nginx/nginx.conf.template +++ b/deploy/examples/nginx/nginx.conf.template @@ -31,6 +31,7 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + # Required for payment provider IP allowlists in webhook handlers. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 47f5656..605f75a 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -41,10 +41,19 @@ | `DB_POOL_RECYCLE_SECONDS` | `.env` | Период recycling DB-соединений. | | `REDIS_URL` | Compose | Redis для FSM, кеша, rate-limit, очередей и locks. В Compose задается автоматически. | | `REDIS_KEY_PREFIX` | `.env` | Префикс Redis-ключей. | -| `TRUSTED_PROXIES` | `.env` | IP/CIDR обратных прокси, которым доверяется `X-Forwarded-For`. | +| `TRUSTED_PROXIES` | `.env` | IP/CIDR обратных прокси, которым доверяется `X-Forwarded-For`. По умолчанию включает loopback и private ranges для Docker/LAN/Kubernetes proxy. | | `HTTP_BIND` / `HTTPS_BIND` | Caddy Compose | Адреса публикации Caddy-варианта. | | `NEWT_ID` / `NEWT_SECRET` | Dev Compose | Доступы Newt в dev-compose. | +`TRUSTED_PROXIES` нужен не только для логов: платежные webhook-обработчики с IP-фильтром +сравнивают allowlist провайдера с client IP после обработки `X-Forwarded-For`. Если внешний +proxy не передает этот заголовок или его IP не входит в `TRUSTED_PROXIES`, backend увидит IP +proxy/Docker gateway и может отклонить валидный webhook. Для Caddy/Nginx/Newt из +`deploy/examples` дефолта достаточно; в кастомной инфраструктуре добавьте CIDR своего proxy +или сузьте значение до конкретных proxy IP. Trust-all вариант записывается как +`0.0.0.0/0,::/0`, но он безопасен только если backend не доступен напрямую, а внешний proxy +очищает входящий `X-Forwarded-For`. + ## Кеши, rate limits и worker Обычно эти значения не требуют правки. diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 98d04c4..19c368c 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -28,6 +28,14 @@ openssl rand -hex 32 - `SUBSCRIPTION_MINI_APP_URL` должен вести на frontend/Mini App-домен. - Не добавляйте `/api`, `/auth` или webhook-пути в `SUBSCRIPTION_MINI_APP_URL`. +## IP allowlist вебхуков + +- Reverse proxy для `WEBHOOK_BASE_URL` должен передавать `X-Forwarded-For` с реальным IP отправителя. +- `TRUSTED_PROXIES` должен включать IP/CIDR последнего proxy-hop до backend. Иначе платежные webhook-обработчики будут проверять allowlist по IP proxy или Docker gateway. +- В Docker Compose профилях Caddy, Nginx и Pangolin/Newt дефолт покрывает loopback и private ranges: `127.0.0.1`, `::1`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`. +- Если backend находится в общей Docker-сети с недоверенными контейнерами, сузьте `TRUSTED_PROXIES` до конкретных IP ваших reverse proxy. +- Если вы сознательно хотите доверять любому proxy-hop, используйте `0.0.0.0/0,::/0`, но только когда backend не опубликован напрямую, а внешний proxy очищает входящий `X-Forwarded-For`. + ## Дополнительно - Используйте HTTPS на всех публичных доменах. diff --git a/docs/features/payments.md b/docs/features/payments.md index 45c85bb..3a5d7fc 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -28,6 +28,12 @@ Все платежные webhook URL строятся от `WEBHOOK_BASE_URL` - публичного HTTPS-адреса backend/webhook-домена. Это должен быть домен, который проксируется на backend-сервер вебхуков (`backend:8080`), а не `SUBSCRIPTION_MINI_APP_URL` frontend/Mini App. Если `WEBHOOK_BASE_URL=https://bot.example.com`, то полный адрес получается как `https://bot.example.com` + путь из таблицы. +Если у провайдера включена IP-фильтрация (`FREEKASSA_TRUSTED_IPS`, `WATA_TRUSTED_IPS`, +`HELEKET_TRUSTED_IPS`, `PAYKILLA_TRUSTED_IPS` или встроенный allowlist YooKassa), +reverse proxy должен прокидывать `X-Forwarded-For`, а его IP/CIDR должен входить в +`TRUSTED_PROXIES`. Иначе backend увидит IP proxy/Docker gateway и может отклонить +валидный webhook с ошибкой `403`. + | Провайдер | Что указать в кабинете провайдера | Комментарий | | --- | --- | --- | | YooKassa | `WEBHOOK_BASE_URL` + `/webhook/yookassa` | Например `https://bot.example.com/webhook/yookassa`. | diff --git a/docs/getting-started/deployment.md b/docs/getting-started/deployment.md index 7ebd905..de41920 100644 --- a/docs/getting-started/deployment.md +++ b/docs/getting-started/deployment.md @@ -433,6 +433,16 @@ docker compose up -d backend worker - Mini App/frontend-домен целиком идет в `frontend:80`; - API/auth/theme routes Mini App дальше проксируются frontend nginx в `backend:8081`. +Для платежных провайдеров с IP allowlist важно, чтобы reverse proxy передавал реальный IP +отправителя в `X-Forwarded-For`, а backend доверял IP последнего proxy-hop через +`TRUSTED_PROXIES`. Готовые профили `caddy`, `nginx` и `newt` уже доверяют loopback и +private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`), чтобы +Docker/LAN/Kubernetes proxy не ломал проверки `YOOKASSA`, `FREEKASSA_TRUSTED_IPS`, +`WATA_TRUSTED_IPS`, `HELEKET_TRUSTED_IPS` и `PAYKILLA_TRUSTED_IPS`. Если в вашей +Docker-сети есть недоверенные контейнеры, сузьте `TRUSTED_PROXIES` до конкретного IP +Caddy/Nginx/Newt. Trust-all режим возможен через `0.0.0.0/0,::/0`, но используйте его +только когда backend недоступен напрямую, а внешний proxy очищает входящий `X-Forwarded-For`. + Минимальная логика Caddy: ```caddyfile diff --git a/scripts/install.sh b/scripts/install.sh index df4fb41..ebb8ef0 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -483,7 +483,7 @@ prompt_common_env() { WEBHOOK_HOST_VALUE="$PROMPT_VALUE" prompt_value "Mini App public hostname" "$(env_get MINIAPP_HOST app.example.com)" 1 0 "hostname" MINIAPP_HOST_VALUE="$PROMPT_VALUE" - TRUSTED_PROXIES_VALUE="$(env_get TRUSTED_PROXIES '127.0.0.1,::1,172.16.0.0/12')" + TRUSTED_PROXIES_VALUE="$(env_get TRUSTED_PROXIES '127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7')" ;; esac diff --git a/tests/test_security.py b/tests/test_security.py index e3d2b22..e471d1d 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -37,12 +37,59 @@ from db.database_setup import redacted_database_url class RequestSecurityTests(unittest.IsolatedAsyncioTestCase): - async def test_request_client_ip_uses_last_forwarded_for_value_for_trusted_proxy(self): + async def test_request_client_ip_uses_rightmost_untrusted_forwarded_ip(self): request = SimpleNamespace( remote="127.0.0.1", headers={"X-Forwarded-For": "203.0.113.10, 198.51.100.7"}, ) + self.assertEqual( + request_client_ip( + request, + trusted_proxies=["127.0.0.1", "198.51.100.0/24"], + ), + "203.0.113.10", + ) + + async def test_request_client_ip_ignores_spoofed_forwarded_prefix(self): + request = SimpleNamespace( + remote="127.0.0.1", + headers={"X-Forwarded-For": "198.51.100.200, 203.0.113.10"}, + ) + + self.assertEqual( + request_client_ip(request, trusted_proxies=["127.0.0.1"]), + "203.0.113.10", + ) + + async def test_request_client_ip_ignores_forwarded_for_from_untrusted_remote(self): + request = SimpleNamespace( + remote="203.0.113.50", + headers={"X-Forwarded-For": "198.51.100.200, 192.0.2.10"}, + ) + + self.assertEqual( + request_client_ip(request, trusted_proxies=["127.0.0.1"]), + "203.0.113.50", + ) + + async def test_request_client_ip_uses_leftmost_forwarded_ip_when_all_hops_are_trusted(self): + request = SimpleNamespace( + remote="127.0.0.1", + headers={"X-Forwarded-For": "172.18.0.4, 172.18.0.5"}, + ) + + self.assertEqual( + request_client_ip(request, trusted_proxies=["127.0.0.1", "172.16.0.0/12"]), + "172.18.0.4", + ) + + async def test_request_client_ip_keeps_last_forwarded_ip_without_remote(self): + request = SimpleNamespace( + remote=None, + headers={"X-Forwarded-For": "203.0.113.10, 198.51.100.7"}, + ) + self.assertEqual( request_client_ip(request, trusted_proxies=["127.0.0.1"]), "198.51.100.7", diff --git a/tests/test_settings.py b/tests/test_settings.py index 78bb268..9343738 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -42,6 +42,26 @@ class SettingsTests(unittest.TestCase): self.assertEqual(settings.WEBAPP_TITLE, "/minishop") + def test_trusted_proxies_default_includes_private_proxy_ranges(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + ) + + self.assertEqual( + settings.trusted_proxies, + [ + "127.0.0.1", + "::1", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7", + ], + ) + def test_panel_write_mode_defaults_to_live_in_production(self): settings = Settings( _env_file=None,