fix: resolve webhook client IP behind proxies
This commit is contained in:
+3
-2
@@ -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
|
||||
|
||||
@@ -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** в админке.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Обычно эти значения не требуют правки.
|
||||
|
||||
@@ -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 на всех публичных доменах.
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+48
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user