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
|
FRONTEND_PORT=8082
|
||||||
|
|
||||||
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||||
# Keep loopback for local proxy; add your proxy network if needed.
|
# Keep loopback and private network ranges so payment provider IP allowlists
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1
|
# 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) ──────────────────────────────
|
# ─── Anonymous install telemetry (opt-out) ──────────────────────────────
|
||||||
# Once a day the worker sends a single anonymous "heartbeat" so the project
|
# 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` - стабильные секреты;
|
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты;
|
||||||
- `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`;
|
- `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`;
|
||||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
- `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 админке.
|
- остальные настройки удобнее задать в Web App админке.
|
||||||
|
|
||||||
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
|
В 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
|
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()]
|
candidates = [item.strip() for item in header_value.split(",") if item.strip()]
|
||||||
if not candidates:
|
parsed: list[ipaddress._BaseAddress] = []
|
||||||
return None
|
for candidate in candidates:
|
||||||
candidate = candidates[-1]
|
parsed_ip = _parse_ip(candidate)
|
||||||
return candidate if _parse_ip(candidate) is not None else None
|
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(
|
def request_client_ip(
|
||||||
@@ -48,20 +62,21 @@ def request_client_ip(
|
|||||||
trusted_proxies: Optional[Sequence[str] | str] = None,
|
trusted_proxies: Optional[Sequence[str] | str] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
remote_ip = _parse_ip(request.remote or "")
|
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)
|
trusted_networks = parse_ip_entries(trusted_proxies)
|
||||||
if any(remote_ip in network for network in trusted_networks):
|
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:
|
if forwarded_ip:
|
||||||
return forwarded_ip
|
return forwarded_ip
|
||||||
|
|
||||||
if remote_ip:
|
if remote_ip:
|
||||||
return str(remote_ip)
|
return str(remote_ip)
|
||||||
|
|
||||||
forwarded_ip = _last_forwarded_ip(forwarded_for)
|
if forwarded_ips:
|
||||||
return forwarded_ip
|
return str(forwarded_ips[-1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def ip_in_allowlist(
|
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):
|
class DBSettings(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
@@ -298,7 +309,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
WEBHOOK_BASE_URL: Optional[str] = None
|
WEBHOOK_BASE_URL: Optional[str] = None
|
||||||
TRUSTED_PROXIES: Optional[str] = Field(
|
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
|
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 {
|
app.example.com {
|
||||||
encode zstd gzip
|
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
|
reverse_proxy backend:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
web.example.com {
|
web.example.com {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Keep forwarded headers for Web App URL generation and request logging.
|
||||||
reverse_proxy frontend:80
|
reverse_proxy frontend:80
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ server {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
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-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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_API_KEY=change_me
|
||||||
PANEL_WEBHOOK_SECRET=change_me
|
PANEL_WEBHOOK_SECRET=change_me
|
||||||
|
|
||||||
# Caddy and Docker network ranges that may set X-Forwarded-For.
|
# Caddy and private Docker/LAN ranges that may set X-Forwarded-For.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
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} {
|
{$WEBHOOK_HOST} {
|
||||||
encode zstd gzip
|
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
|
reverse_proxy backend:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
{$MINIAPP_HOST} {
|
{$MINIAPP_HOST} {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Keep forwarded headers for Web App URL generation and request logging.
|
||||||
reverse_proxy frontend:80
|
reverse_proxy frontend:80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
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:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
- ${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_API_KEY=change_me
|
||||||
PANEL_WEBHOOK_SECRET=change_me
|
PANEL_WEBHOOK_SECRET=change_me
|
||||||
|
|
||||||
# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For.
|
# Pangolin/Newt and private Docker/LAN ranges that may set X-Forwarded-For.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
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:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
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:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
- ${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_API_KEY=change_me
|
||||||
PANEL_WEBHOOK_SECRET=change_me
|
PANEL_WEBHOOK_SECRET=change_me
|
||||||
|
|
||||||
# Nginx and Docker network ranges that may set X-Forwarded-For.
|
# Nginx and private Docker/LAN ranges that may set X-Forwarded-For.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
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:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
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:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ server {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
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-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|||||||
@@ -41,10 +41,19 @@
|
|||||||
| `DB_POOL_RECYCLE_SECONDS` | `.env` | Период recycling DB-соединений. |
|
| `DB_POOL_RECYCLE_SECONDS` | `.env` | Период recycling DB-соединений. |
|
||||||
| `REDIS_URL` | Compose | Redis для FSM, кеша, rate-limit, очередей и locks. В Compose задается автоматически. |
|
| `REDIS_URL` | Compose | Redis для FSM, кеша, rate-limit, очередей и locks. В Compose задается автоматически. |
|
||||||
| `REDIS_KEY_PREFIX` | `.env` | Префикс Redis-ключей. |
|
| `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-варианта. |
|
| `HTTP_BIND` / `HTTPS_BIND` | Caddy Compose | Адреса публикации Caddy-варианта. |
|
||||||
| `NEWT_ID` / `NEWT_SECRET` | Dev Compose | Доступы Newt в dev-compose. |
|
| `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
|
## Кеши, rate limits и worker
|
||||||
|
|
||||||
Обычно эти значения не требуют правки.
|
Обычно эти значения не требуют правки.
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ openssl rand -hex 32
|
|||||||
- `SUBSCRIPTION_MINI_APP_URL` должен вести на frontend/Mini App-домен.
|
- `SUBSCRIPTION_MINI_APP_URL` должен вести на frontend/Mini App-домен.
|
||||||
- Не добавляйте `/api`, `/auth` или webhook-пути в `SUBSCRIPTION_MINI_APP_URL`.
|
- Не добавляйте `/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 на всех публичных доменах.
|
- Используйте 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` + путь из таблицы.
|
Все платежные 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`. |
|
| 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`;
|
- Mini App/frontend-домен целиком идет в `frontend:80`;
|
||||||
- API/auth/theme routes Mini App дальше проксируются frontend nginx в `backend:8081`.
|
- 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:
|
Минимальная логика Caddy:
|
||||||
|
|
||||||
```caddyfile
|
```caddyfile
|
||||||
|
|||||||
+1
-1
@@ -483,7 +483,7 @@ prompt_common_env() {
|
|||||||
WEBHOOK_HOST_VALUE="$PROMPT_VALUE"
|
WEBHOOK_HOST_VALUE="$PROMPT_VALUE"
|
||||||
prompt_value "Mini App public hostname" "$(env_get MINIAPP_HOST app.example.com)" 1 0 "hostname"
|
prompt_value "Mini App public hostname" "$(env_get MINIAPP_HOST app.example.com)" 1 0 "hostname"
|
||||||
MINIAPP_HOST_VALUE="$PROMPT_VALUE"
|
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
|
esac
|
||||||
|
|
||||||
|
|||||||
+48
-1
@@ -37,12 +37,59 @@ from db.database_setup import redacted_database_url
|
|||||||
|
|
||||||
|
|
||||||
class RequestSecurityTests(unittest.IsolatedAsyncioTestCase):
|
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(
|
request = SimpleNamespace(
|
||||||
remote="127.0.0.1",
|
remote="127.0.0.1",
|
||||||
headers={"X-Forwarded-For": "203.0.113.10, 198.51.100.7"},
|
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(
|
self.assertEqual(
|
||||||
request_client_ip(request, trusted_proxies=["127.0.0.1"]),
|
request_client_ip(request, trusted_proxies=["127.0.0.1"]),
|
||||||
"198.51.100.7",
|
"198.51.100.7",
|
||||||
|
|||||||
@@ -42,6 +42,26 @@ class SettingsTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(settings.WEBAPP_TITLE, "/minishop")
|
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):
|
def test_panel_write_mode_defaults_to_live_in_production(self):
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
_env_file=None,
|
_env_file=None,
|
||||||
|
|||||||
Reference in New Issue
Block a user