security: harden webhooks and session secrets
This commit is contained in:
@@ -5,6 +5,12 @@
|
||||
.gitattributes
|
||||
LICENSE
|
||||
README.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
scratch_*.py
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
|
||||
# CI
|
||||
|
||||
+7
-3
@@ -3,8 +3,8 @@ BOT_TOKEN=your_bot_token_here #
|
||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
||||
|
||||
# PostgreSQL Database Connection Settings
|
||||
POSTGRES_USER=postgres # Database user name
|
||||
POSTGRES_PASSWORD=postgres # Database password
|
||||
POSTGRES_USER= # Required: database user name
|
||||
POSTGRES_PASSWORD= # Required: database password
|
||||
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
|
||||
POSTGRES_PORT=5432 # Port
|
||||
POSTGRES_DB=postgres # Database name
|
||||
@@ -31,6 +31,7 @@ REQUIRED_CHANNEL_LINK=https://t.me/your_channel #
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
|
||||
|
||||
# Subscription Mini App (same container, separate port)
|
||||
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
||||
@@ -39,7 +40,9 @@ WEBAPP_SERVER_PORT=8081 #
|
||||
WEBAPP_TITLE="Моя подписка" # Mini App title
|
||||
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
|
||||
WEBAPP_LOGO_URL= # Optional logo URL; shown in the header and login screen, leave empty to hide
|
||||
WEBAPP_SESSION_TTL_SECONDS=2592000 # Web App session lifetime
|
||||
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
|
||||
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
|
||||
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
|
||||
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
|
||||
|
||||
@@ -92,6 +95,7 @@ FREEKASSA_API_KEY=your_api_key #
|
||||
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
|
||||
FREEKASSA_TRUSTED_IPS=168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238 # FreeKassa webhook source IP allowlist
|
||||
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||
|
||||
+5
-1
@@ -3,10 +3,14 @@ bot_database.sqlite3
|
||||
|
||||
# Игнорировать файлы окружения
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
scratch_*.py
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
# Игнорировать кэш Python
|
||||
__pycache__/
|
||||
node_modules/
|
||||
|
||||
# Игнорировать настройки IDE (если используешь, например, PyCharm или VSCode)
|
||||
.idea/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hmac
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
@@ -9,6 +10,13 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
class SecureSimpleRequestHandler(SimpleRequestHandler):
|
||||
def verify_secret(self, telegram_secret_token: str, bot: Bot) -> bool:
|
||||
if not self.secret_token:
|
||||
return False
|
||||
return hmac.compare_digest(telegram_secret_token, self.secret_token)
|
||||
|
||||
TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
@@ -55,8 +63,12 @@ async def build_and_start_web_app(
|
||||
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
|
||||
|
||||
if telegram_uses_webhook_mode:
|
||||
telegram_webhook_path = f"/{settings.BOT_TOKEN}"
|
||||
app.router.add_post(telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot))
|
||||
telegram_webhook_path = settings.telegram_webhook_path
|
||||
SecureSimpleRequestHandler(
|
||||
dispatcher=dp,
|
||||
bot=bot,
|
||||
secret_token=settings.WEBHOOK_SECRET_TOKEN,
|
||||
).register(app, path=telegram_webhook_path)
|
||||
logging.info(
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ def _urlsafe_b64decode(raw: str) -> bytes:
|
||||
|
||||
def _session_secret(settings: Settings) -> bytes:
|
||||
return hmac.new(
|
||||
settings.BOT_TOKEN.encode("utf-8"),
|
||||
settings.WEBAPP_SESSION_SECRET.encode("utf-8"),
|
||||
b"remnawave-tg-shop-webapp-session",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
@@ -25,12 +25,22 @@ from bot.services.notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
|
||||
payment_processing_lock = asyncio.Lock()
|
||||
|
||||
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
|
||||
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
|
||||
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
|
||||
YOOKASSA_WEBHOOK_ALLOWED_IPS = [
|
||||
"185.71.76.0/27",
|
||||
"185.71.77.0/27",
|
||||
"77.75.153.0/25",
|
||||
"77.75.156.11",
|
||||
"77.75.156.35",
|
||||
"77.75.154.128/25",
|
||||
"2a02:5180::/32",
|
||||
]
|
||||
|
||||
|
||||
async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
@@ -484,6 +494,11 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
status=500,
|
||||
text="Internal Server Error: Missing app context component")
|
||||
|
||||
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
|
||||
logging.warning("YooKassa webhook denied from unauthorized IP source.")
|
||||
return web.Response(status=403)
|
||||
|
||||
try:
|
||||
event_json = await request.json()
|
||||
|
||||
|
||||
+50
-50
@@ -40,6 +40,12 @@ from bot.handlers.admin.sync_admin import perform_sync
|
||||
from bot.utils.message_queue import init_queue_manager
|
||||
|
||||
|
||||
def redact_token(value: str, token: Optional[str]) -> str:
|
||||
if not value or not token:
|
||||
return value
|
||||
return value.replace(token, "***")
|
||||
|
||||
|
||||
async def register_all_routers(dp: Dispatcher, settings: Settings):
|
||||
dp.include_router(build_root_router(settings))
|
||||
logging.info("All application routers registered.")
|
||||
@@ -59,52 +65,48 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
|
||||
if telegram_webhook_url_to_set:
|
||||
full_telegram_webhook_url = (
|
||||
f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||
f"{str(telegram_webhook_url_to_set).rstrip('/')}{settings.telegram_webhook_path}"
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
|
||||
"STARTUP: Attempting to set Telegram webhook to: %s",
|
||||
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||
)
|
||||
|
||||
if full_telegram_webhook_url != "ERROR_URL_TOKEN_DETECTED":
|
||||
try:
|
||||
current_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
|
||||
set_success = await bot.set_webhook(
|
||||
url=full_telegram_webhook_url,
|
||||
drop_pending_updates=True,
|
||||
allowed_updates=dispatcher.resolve_used_update_types(),
|
||||
)
|
||||
if set_success:
|
||||
logging.info(
|
||||
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)."
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned FAILURE (False)."
|
||||
)
|
||||
|
||||
new_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
if not new_webhook_info.url:
|
||||
logging.error(
|
||||
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
|
||||
)
|
||||
|
||||
except Exception as e_setwebhook:
|
||||
logging.error(
|
||||
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
|
||||
try:
|
||||
current_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
|
||||
set_success = await bot.set_webhook(
|
||||
url=full_telegram_webhook_url,
|
||||
secret_token=settings.WEBHOOK_SECRET_TOKEN,
|
||||
drop_pending_updates=True,
|
||||
allowed_updates=dispatcher.resolve_used_update_types(),
|
||||
)
|
||||
if set_success:
|
||||
logging.info(
|
||||
"STARTUP: bot.set_webhook to %s returned SUCCESS (True).",
|
||||
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"STARTUP: bot.set_webhook to %s returned FAILURE (False).",
|
||||
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||
)
|
||||
|
||||
new_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
if not new_webhook_info.url:
|
||||
logging.error(
|
||||
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logging.exception("STARTUP: EXCEPTION during set/get Telegram webhook.")
|
||||
else:
|
||||
logging.error(
|
||||
"STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting."
|
||||
@@ -127,10 +129,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
logging.info(
|
||||
"STARTUP: Mini app domain registered and default menu button restored."
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"STARTUP: Failed to register mini app domain: {e}", exc_info=True
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to register mini app domain.")
|
||||
|
||||
try:
|
||||
bot_commands = [
|
||||
@@ -144,16 +144,16 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
)
|
||||
await bot.set_my_commands(bot_commands)
|
||||
logging.info("STARTUP: bot command descriptions set.")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to set bot commands: {e}", exc_info=True)
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to set bot commands.")
|
||||
|
||||
# Initialize message queue manager
|
||||
try:
|
||||
queue_manager = init_queue_manager(bot)
|
||||
dispatcher["queue_manager"] = queue_manager
|
||||
logging.info("STARTUP: Message queue manager initialized")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True)
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to initialize message queue manager.")
|
||||
|
||||
# Automatic sync on startup
|
||||
try:
|
||||
@@ -172,8 +172,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
else:
|
||||
logging.warning(f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to run automatic sync: {e}", exc_info=True)
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to run automatic sync.")
|
||||
|
||||
logging.info("STARTUP: Bot on_startup_configured completed.")
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
@@ -145,6 +145,18 @@ class EmailAuthService:
|
||||
retry_after=resend_after - elapsed,
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
update(EmailVerificationCode)
|
||||
.where(
|
||||
EmailVerificationCode.email == normalized_email,
|
||||
EmailVerificationCode.purpose == purpose,
|
||||
EmailVerificationCode.target_user_id == target_user_id,
|
||||
EmailVerificationCode.status == "active",
|
||||
EmailVerificationCode.consumed_at.is_(None),
|
||||
)
|
||||
.values(status="superseded")
|
||||
)
|
||||
|
||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||
code_model = EmailVerificationCode(
|
||||
email=normalized_email,
|
||||
@@ -152,6 +164,7 @@ class EmailAuthService:
|
||||
purpose=purpose,
|
||||
target_user_id=target_user_id,
|
||||
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
|
||||
status="active",
|
||||
)
|
||||
session.add(code_model)
|
||||
await session.flush()
|
||||
@@ -281,6 +294,8 @@ class EmailAuthService:
|
||||
EmailVerificationCode.email == email,
|
||||
EmailVerificationCode.purpose == purpose,
|
||||
EmailVerificationCode.target_user_id == target_user_id,
|
||||
EmailVerificationCode.status == "active",
|
||||
EmailVerificationCode.consumed_at.is_(None),
|
||||
)
|
||||
.order_by(EmailVerificationCode.created_at.desc())
|
||||
.limit(1)
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
import time
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from aiogram import Bot
|
||||
@@ -21,6 +22,7 @@ from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
|
||||
|
||||
class FreeKassaService:
|
||||
@@ -169,69 +171,59 @@ class FreeKassaService:
|
||||
|
||||
def _validate_signature(
|
||||
self,
|
||||
merchant_order_id: str,
|
||||
amount: str,
|
||||
raw_body: bytes,
|
||||
provided_signature: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
if not provided_signature:
|
||||
return False
|
||||
if not self.second_secret:
|
||||
return False
|
||||
|
||||
if self.shop_id and self.second_secret:
|
||||
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
|
||||
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
|
||||
if expected_signature.lower() == provided_signature.lower():
|
||||
return True
|
||||
|
||||
if self.api_key and payload:
|
||||
items = [
|
||||
(key, value)
|
||||
for key, value in payload.items()
|
||||
if key not in {"signature", "SIGN"} and value is not None
|
||||
]
|
||||
items.sort(key=lambda pair: pair[0])
|
||||
message = "|".join(str(value) for _, value in items)
|
||||
alt_signature = hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
if alt_signature.lower() == provided_signature.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
expected_signature = hmac.new(
|
||||
self.second_secret.encode("utf-8"),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected_signature, provided_signature)
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.Response(status=503, text="freekassa_disabled")
|
||||
|
||||
try:
|
||||
data = await request.post()
|
||||
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
|
||||
if not ip_in_allowlist(client_ip, self.settings.freekassa_trusted_ips):
|
||||
return web.Response(status=403)
|
||||
|
||||
raw_body = await request.read()
|
||||
except Exception as e:
|
||||
logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
|
||||
logging.error("FreeKassa webhook: failed to read request body: %s", e)
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
payload_dict: Dict[str, Any]
|
||||
if data:
|
||||
payload_dict = {str(k): v for k, v in data.items()}
|
||||
else:
|
||||
payload_dict: Dict[str, Any] = {}
|
||||
if raw_body:
|
||||
try:
|
||||
json_payload = await request.json()
|
||||
payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {}
|
||||
data = json_payload
|
||||
if request.content_type.startswith("application/json"):
|
||||
decoded_json = json.loads(raw_body.decode("utf-8"))
|
||||
if isinstance(decoded_json, dict):
|
||||
payload_dict = {str(k): v for k, v in decoded_json.items()}
|
||||
else:
|
||||
payload_dict = {
|
||||
str(key): value
|
||||
for key, value in parse_qsl(raw_body.decode("utf-8"), keep_blank_values=True)
|
||||
}
|
||||
except Exception:
|
||||
payload_dict = {}
|
||||
data = {}
|
||||
|
||||
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
if isinstance(data, dict):
|
||||
return data.get(key) or data.get(key.lower()) or default
|
||||
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
|
||||
|
||||
merchant_id = _get("MERCHANT_ID")
|
||||
if merchant_id != self.shop_id:
|
||||
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
|
||||
return web.Response(status=403, text="merchant_mismatch")
|
||||
return web.Response(status=403)
|
||||
|
||||
signature = _get("SIGN") or _get("signature")
|
||||
if not signature:
|
||||
logging.error("FreeKassa webhook: missing signature")
|
||||
return web.Response(status=400, text="missing_signature")
|
||||
|
||||
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
|
||||
@@ -239,11 +231,9 @@ class FreeKassaService:
|
||||
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
|
||||
|
||||
if not order_id_str or not amount_str:
|
||||
logging.error("FreeKassa webhook: missing order_id or amount")
|
||||
return web.Response(status=400, text="missing_data")
|
||||
|
||||
if not self._validate_signature(order_id_str, amount_str, signature, payload_dict):
|
||||
logging.error("FreeKassa webhook: invalid signature")
|
||||
if not self._validate_signature(raw_body, signature):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
try:
|
||||
|
||||
@@ -26,6 +26,10 @@ class PanelWebhookService:
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.panel_service = panel_service
|
||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||
logging.error(
|
||||
"PANEL_WEBHOOK_SECRET is not configured. Panel webhooks will be rejected."
|
||||
)
|
||||
|
||||
async def _send_message(
|
||||
self,
|
||||
@@ -40,8 +44,8 @@ class PanelWebhookService:
|
||||
await self.bot.send_message(
|
||||
user_id, _(message_key, **kwargs), reply_markup=reply_markup
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to {user_id}: {e}")
|
||||
except Exception:
|
||||
logging.exception("Failed to send notification to %s", user_id)
|
||||
|
||||
async def handle_event(self, event_name: str, user_payload: dict):
|
||||
telegram_id = user_payload.get("telegramId")
|
||||
@@ -139,16 +143,19 @@ class PanelWebhookService:
|
||||
)
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
if self.settings.PANEL_WEBHOOK_SECRET:
|
||||
if not signature_header:
|
||||
return web.Response(status=403, text="no_signature")
|
||||
expected_sig = hmac.new(
|
||||
self.settings.PANEL_WEBHOOK_SECRET.encode(),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||
return web.Response(status=401, text="unauthorized")
|
||||
|
||||
if not signature_header:
|
||||
return web.Response(status=401, text="unauthorized")
|
||||
|
||||
expected_sig = hmac.new(
|
||||
self.settings.PANEL_WEBHOOK_SECRET.encode(),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.Response(status=401, text="unauthorized")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
@@ -141,7 +142,10 @@ class PlategaService:
|
||||
|
||||
header_merchant = request.headers.get("X-MerchantId")
|
||||
header_secret = request.headers.get("X-Secret")
|
||||
if header_merchant != self.merchant_id or header_secret != self.secret:
|
||||
if not (
|
||||
hmac.compare_digest(str(header_merchant or ""), str(self.merchant_id or ""))
|
||||
and hmac.compare_digest(str(header_secret or ""), str(self.secret or ""))
|
||||
):
|
||||
logging.error("Platega webhook: invalid auth headers")
|
||||
return web.Response(status=403, text="forbidden")
|
||||
|
||||
|
||||
@@ -161,10 +161,11 @@ class YooKassaService:
|
||||
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.create(payment_request,
|
||||
idempotence_key))
|
||||
response = await asyncio.to_thread(
|
||||
YooKassaPayment.create,
|
||||
payment_request,
|
||||
idempotence_key,
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}"
|
||||
@@ -216,9 +217,10 @@ class YooKassaService:
|
||||
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
payment_info_yk = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
|
||||
payment_info_yk = await asyncio.to_thread(
|
||||
YooKassaPayment.find_one,
|
||||
payment_id_in_yookassa,
|
||||
)
|
||||
|
||||
if payment_info_yk:
|
||||
logging.info(
|
||||
@@ -275,8 +277,7 @@ class YooKassaService:
|
||||
logging.error("YooKassa is not configured. Cannot cancel payment.")
|
||||
return False
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa))
|
||||
await asyncio.to_thread(YooKassaPayment.cancel, payment_id_in_yookassa)
|
||||
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
def parse_ip_entries(raw_values: Optional[Sequence[str] | str]) -> list[ipaddress._BaseNetwork]:
|
||||
if raw_values is None:
|
||||
return []
|
||||
if isinstance(raw_values, str):
|
||||
values = [item.strip() for item in raw_values.split(",")]
|
||||
else:
|
||||
values = [str(item).strip() for item in raw_values]
|
||||
|
||||
parsed: list[ipaddress._BaseNetwork] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
parsed.append(ipaddress.ip_network(value, strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_ip(value: Optional[str]) -> Optional[ipaddress._BaseAddress]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return ipaddress.ip_address(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _last_forwarded_ip(header_value: str) -> Optional[str]:
|
||||
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
|
||||
|
||||
|
||||
def request_client_ip(
|
||||
request: web.Request,
|
||||
*,
|
||||
trusted_proxies: Optional[Sequence[str] | str] = None,
|
||||
) -> Optional[str]:
|
||||
remote_ip = _parse_ip(request.remote or "")
|
||||
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
||||
|
||||
if remote_ip and forwarded_for:
|
||||
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)
|
||||
if forwarded_ip:
|
||||
return forwarded_ip
|
||||
|
||||
if remote_ip:
|
||||
return str(remote_ip)
|
||||
|
||||
forwarded_ip = _last_forwarded_ip(forwarded_for)
|
||||
return forwarded_ip
|
||||
|
||||
|
||||
def ip_in_allowlist(ip_value: Optional[str], allowed_entries: Optional[Sequence[str] | str]) -> bool:
|
||||
parsed_ip = _parse_ip(ip_value)
|
||||
if parsed_ip is None:
|
||||
return False
|
||||
|
||||
allowed_networks = parse_ip_entries(allowed_entries)
|
||||
return any(parsed_ip in network for network in allowed_networks)
|
||||
+239
-5
@@ -1,8 +1,97 @@
|
||||
import logging
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import Field, ValidationError, computed_field, field_validator
|
||||
import os
|
||||
import secrets
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, computed_field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def _split_csv(value: Optional[str]) -> List[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
class DBSettings(BaseModel):
|
||||
user: str
|
||||
password: str
|
||||
host: str
|
||||
port: int
|
||||
database: str
|
||||
|
||||
|
||||
class PaymentSettings(BaseModel):
|
||||
yookassa_enabled: bool
|
||||
yookassa_shop_id: Optional[str]
|
||||
yookassa_secret_key: Optional[str]
|
||||
yookassa_return_url: Optional[str]
|
||||
yookassa_default_receipt_email: Optional[str]
|
||||
yookassa_vat_code: int
|
||||
yookassa_payment_mode: str
|
||||
yookassa_payment_subject: str
|
||||
yookassa_autopayments_enabled: bool
|
||||
yookassa_autopayments_require_card_binding: bool
|
||||
freekassa_enabled: bool
|
||||
freekassa_merchant_id: Optional[str]
|
||||
freekassa_second_secret: Optional[str]
|
||||
freekassa_api_key: Optional[str]
|
||||
freekassa_payment_ip: Optional[str]
|
||||
freekassa_payment_method_id: Optional[int]
|
||||
freekassa_trusted_ips: List[str]
|
||||
platega_enabled: bool
|
||||
platega_base_url: str
|
||||
platega_merchant_id: Optional[str]
|
||||
platega_secret: Optional[str]
|
||||
platega_payment_method: int
|
||||
platega_return_url: Optional[str]
|
||||
platega_failed_url: Optional[str]
|
||||
severpay_enabled: bool
|
||||
severpay_mid: Optional[int]
|
||||
severpay_token: Optional[str]
|
||||
severpay_return_url: Optional[str]
|
||||
severpay_base_url: str
|
||||
severpay_lifetime_minutes: Optional[int]
|
||||
cryptopay_enabled: bool
|
||||
cryptopay_token: Optional[str]
|
||||
cryptopay_network: str
|
||||
cryptopay_currency_type: str
|
||||
cryptopay_asset: str
|
||||
|
||||
|
||||
class EmailSettings(BaseModel):
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_fallback_ports: Optional[str]
|
||||
smtp_timeout_seconds: int
|
||||
smtp_username: Optional[str]
|
||||
smtp_password: Optional[str]
|
||||
smtp_from_email: Optional[str]
|
||||
smtp_from_name: Optional[str]
|
||||
smtp_starttls: bool
|
||||
smtp_use_ssl: bool
|
||||
email_code_ttl_seconds: int
|
||||
email_code_resend_seconds: int
|
||||
email_code_max_attempts: int
|
||||
brute_force_max_failures: int
|
||||
brute_force_window_seconds: int
|
||||
brute_force_lock_seconds: int
|
||||
|
||||
|
||||
class WebAppSettings(BaseModel):
|
||||
title: str
|
||||
primary_color: str
|
||||
logo_url: Optional[str]
|
||||
session_ttl_seconds: int
|
||||
session_secret: str
|
||||
webhook_secret_token: str
|
||||
auth_max_age_seconds: int
|
||||
login_token_ttl_seconds: int
|
||||
server_host: str
|
||||
server_port: int
|
||||
enabled: bool
|
||||
trusted_proxies: List[str]
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
BOT_TOKEN: str
|
||||
@@ -11,8 +100,8 @@ class Settings(BaseSettings):
|
||||
alias="ADMIN_IDS",
|
||||
description="Comma-separated list of admin Telegram User IDs")
|
||||
|
||||
POSTGRES_USER: str = Field(default="user")
|
||||
POSTGRES_PASSWORD: str = Field(default="password")
|
||||
POSTGRES_USER: str = Field(...)
|
||||
POSTGRES_PASSWORD: str = Field(...)
|
||||
POSTGRES_HOST: str = Field(default="localhost")
|
||||
POSTGRES_PORT: int = Field(default=5432)
|
||||
POSTGRES_DB: str = Field(default="vpn_shop_db")
|
||||
@@ -75,6 +164,10 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
WEBHOOK_BASE_URL: Optional[str] = None
|
||||
TRUSTED_PROXIES: Optional[str] = Field(
|
||||
default="127.0.0.1,::1",
|
||||
description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.",
|
||||
)
|
||||
|
||||
CRYPTOPAY_TOKEN: Optional[str] = None
|
||||
CRYPTOPAY_NETWORK: str = Field(default="mainnet")
|
||||
@@ -99,6 +192,10 @@ class Settings(BaseSettings):
|
||||
FREEKASSA_API_KEY: Optional[str] = None
|
||||
FREEKASSA_PAYMENT_IP: Optional[str] = None
|
||||
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
|
||||
FREEKASSA_TRUSTED_IPS: str = Field(
|
||||
default="168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238",
|
||||
description="Comma-separated FreeKassa webhook IP allowlist.",
|
||||
)
|
||||
|
||||
SEVERPAY_ENABLED: bool = Field(default=False)
|
||||
SEVERPAY_MID: Optional[int] = None
|
||||
@@ -211,7 +308,9 @@ class Settings(BaseSettings):
|
||||
WEBAPP_TITLE: str = Field(default="Моя подписка")
|
||||
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
|
||||
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
||||
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=30 * 24 * 60 * 60)
|
||||
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
|
||||
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
|
||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
|
||||
|
||||
@@ -268,6 +367,98 @@ class Settings(BaseSettings):
|
||||
def DATABASE_URL(self) -> str:
|
||||
return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def db_settings(self) -> DBSettings:
|
||||
return DBSettings(
|
||||
user=self.POSTGRES_USER,
|
||||
password=self.POSTGRES_PASSWORD,
|
||||
host=self.POSTGRES_HOST,
|
||||
port=self.POSTGRES_PORT,
|
||||
database=self.POSTGRES_DB,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def payment_settings(self) -> PaymentSettings:
|
||||
return PaymentSettings(
|
||||
yookassa_enabled=self.YOOKASSA_ENABLED,
|
||||
yookassa_shop_id=self.YOOKASSA_SHOP_ID,
|
||||
yookassa_secret_key=self.YOOKASSA_SECRET_KEY,
|
||||
yookassa_return_url=self.YOOKASSA_RETURN_URL,
|
||||
yookassa_default_receipt_email=self.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
|
||||
yookassa_vat_code=self.YOOKASSA_VAT_CODE,
|
||||
yookassa_payment_mode=self.YOOKASSA_PAYMENT_MODE,
|
||||
yookassa_payment_subject=self.YOOKASSA_PAYMENT_SUBJECT,
|
||||
yookassa_autopayments_enabled=self.YOOKASSA_AUTOPAYMENTS_ENABLED,
|
||||
yookassa_autopayments_require_card_binding=self.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING,
|
||||
freekassa_enabled=self.FREEKASSA_ENABLED,
|
||||
freekassa_merchant_id=self.FREEKASSA_MERCHANT_ID,
|
||||
freekassa_second_secret=self.FREEKASSA_SECOND_SECRET,
|
||||
freekassa_api_key=self.FREEKASSA_API_KEY,
|
||||
freekassa_payment_ip=self.FREEKASSA_PAYMENT_IP,
|
||||
freekassa_payment_method_id=self.FREEKASSA_PAYMENT_METHOD_ID,
|
||||
freekassa_trusted_ips=self.freekassa_trusted_ips,
|
||||
platega_enabled=self.PLATEGA_ENABLED,
|
||||
platega_base_url=self.PLATEGA_BASE_URL,
|
||||
platega_merchant_id=self.PLATEGA_MERCHANT_ID,
|
||||
platega_secret=self.PLATEGA_SECRET,
|
||||
platega_payment_method=self.PLATEGA_PAYMENT_METHOD,
|
||||
platega_return_url=self.PLATEGA_RETURN_URL,
|
||||
platega_failed_url=self.PLATEGA_FAILED_URL,
|
||||
severpay_enabled=self.SEVERPAY_ENABLED,
|
||||
severpay_mid=self.SEVERPAY_MID,
|
||||
severpay_token=self.SEVERPAY_TOKEN,
|
||||
severpay_return_url=self.SEVERPAY_RETURN_URL,
|
||||
severpay_base_url=self.SEVERPAY_BASE_URL,
|
||||
severpay_lifetime_minutes=self.SEVERPAY_LIFETIME_MINUTES,
|
||||
cryptopay_enabled=self.CRYPTOPAY_ENABLED,
|
||||
cryptopay_token=self.CRYPTOPAY_TOKEN,
|
||||
cryptopay_network=self.CRYPTOPAY_NETWORK,
|
||||
cryptopay_currency_type=self.CRYPTOPAY_CURRENCY_TYPE,
|
||||
cryptopay_asset=self.CRYPTOPAY_ASSET,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def email_settings(self) -> EmailSettings:
|
||||
return EmailSettings(
|
||||
smtp_host=self.SMTP_HOST,
|
||||
smtp_port=self.SMTP_PORT,
|
||||
smtp_fallback_ports=self.SMTP_FALLBACK_PORTS,
|
||||
smtp_timeout_seconds=self.SMTP_TIMEOUT_SECONDS,
|
||||
smtp_username=self.SMTP_USERNAME,
|
||||
smtp_password=self.SMTP_PASSWORD,
|
||||
smtp_from_email=self.SMTP_FROM_EMAIL,
|
||||
smtp_from_name=self.SMTP_FROM_NAME,
|
||||
smtp_starttls=self.SMTP_STARTTLS,
|
||||
smtp_use_ssl=self.SMTP_USE_SSL,
|
||||
email_code_ttl_seconds=self.EMAIL_CODE_TTL_SECONDS,
|
||||
email_code_resend_seconds=self.EMAIL_CODE_RESEND_SECONDS,
|
||||
email_code_max_attempts=self.EMAIL_CODE_MAX_ATTEMPTS,
|
||||
brute_force_max_failures=self.BRUTE_FORCE_MAX_FAILURES,
|
||||
brute_force_window_seconds=self.BRUTE_FORCE_WINDOW_SECONDS,
|
||||
brute_force_lock_seconds=self.BRUTE_FORCE_LOCK_SECONDS,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def webapp_settings(self) -> WebAppSettings:
|
||||
return WebAppSettings(
|
||||
title=self.WEBAPP_TITLE,
|
||||
primary_color=self.WEBAPP_PRIMARY_COLOR,
|
||||
logo_url=self.WEBAPP_LOGO_URL,
|
||||
session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
|
||||
session_secret=self.WEBAPP_SESSION_SECRET,
|
||||
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
|
||||
auth_max_age_seconds=self.WEBAPP_AUTH_MAX_AGE_SECONDS,
|
||||
login_token_ttl_seconds=self.WEBAPP_LOGIN_TOKEN_TTL_SECONDS,
|
||||
server_host=self.WEBAPP_SERVER_HOST,
|
||||
server_port=self.WEBAPP_SERVER_PORT,
|
||||
enabled=self.WEBAPP_ENABLED,
|
||||
trusted_proxies=self.trusted_proxies,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def ADMIN_IDS(self) -> List[int]:
|
||||
@@ -325,6 +516,21 @@ class Settings(BaseSettings):
|
||||
return cleaned
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def trusted_proxies(self) -> List[str]:
|
||||
return _split_csv(self.TRUSTED_PROXIES)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def freekassa_trusted_ips(self) -> List[str]:
|
||||
return _split_csv(self.FREEKASSA_TRUSTED_IPS)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def telegram_webhook_path(self) -> str:
|
||||
return "/tg/webhook"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def yookassa_webhook_path(self) -> str:
|
||||
@@ -605,6 +811,26 @@ class Settings(BaseSettings):
|
||||
return "INFO"
|
||||
return v
|
||||
|
||||
@field_validator('POSTGRES_USER', 'POSTGRES_PASSWORD', mode='before')
|
||||
@classmethod
|
||||
def validate_required_db_credentials(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("must not be empty")
|
||||
return v
|
||||
|
||||
@field_validator('WEBAPP_SESSION_SECRET', 'WEBHOOK_SECRET_TOKEN', mode='before')
|
||||
@classmethod
|
||||
def normalize_webapp_secrets(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip()
|
||||
if v:
|
||||
return v
|
||||
if v:
|
||||
return v
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
|
||||
@classmethod
|
||||
def validate_optional_int_fields(cls, v):
|
||||
@@ -675,6 +901,14 @@ def get_settings() -> Settings:
|
||||
logging.warning(
|
||||
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
|
||||
)
|
||||
if not os.getenv("WEBAPP_SESSION_SECRET"):
|
||||
logging.warning(
|
||||
"WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only."
|
||||
)
|
||||
if not os.getenv("WEBHOOK_SECRET_TOKEN"):
|
||||
logging.warning(
|
||||
"WEBHOOK_SECRET_TOKEN is not set. A generated secret will be used for this process only."
|
||||
)
|
||||
if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY:
|
||||
logging.warning(
|
||||
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
||||
|
||||
@@ -11,22 +11,10 @@ from db.database_setup import init_db, init_db_connection
|
||||
|
||||
|
||||
def _resolve_log_level(value: str) -> int:
|
||||
if not value:
|
||||
return logging.INFO
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return logging.INFO
|
||||
if normalized.isdigit():
|
||||
return int(normalized)
|
||||
level = getattr(logging, normalized.upper(), None)
|
||||
if isinstance(level, int):
|
||||
return level
|
||||
return logging.INFO
|
||||
return getattr(logging, value.upper(), logging.INFO)
|
||||
|
||||
|
||||
async def main():
|
||||
load_dotenv()
|
||||
settings = get_settings()
|
||||
|
||||
session_factory = init_db_connection(settings)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from bot.handlers.user.payment import yookassa_webhook_route
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.utils.request_security import request_client_ip
|
||||
|
||||
|
||||
class RequestSecurityTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_request_client_ip_uses_last_forwarded_for_value_for_trusted_proxy(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.7",
|
||||
)
|
||||
|
||||
async def test_yookassa_webhook_rejects_untrusted_ip_before_reading_body(self):
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"bot": object(),
|
||||
"i18n": object(),
|
||||
"settings": SimpleNamespace(trusted_proxies=["127.0.0.1"]),
|
||||
"panel_service": object(),
|
||||
"subscription_service": object(),
|
||||
"referral_service": object(),
|
||||
"lknpd_service": None,
|
||||
"async_session_factory": object(),
|
||||
},
|
||||
headers={},
|
||||
remote="203.0.113.50",
|
||||
json=AsyncMock(side_effect=AssertionError("request.json() must not be called")),
|
||||
)
|
||||
|
||||
response = await yookassa_webhook_route(request)
|
||||
|
||||
self.assertEqual(response.status, 403)
|
||||
request.json.assert_not_awaited()
|
||||
|
||||
|
||||
class FreeKassaServiceTests(unittest.TestCase):
|
||||
def _make_service(self) -> FreeKassaService:
|
||||
settings = SimpleNamespace(
|
||||
FREEKASSA_ENABLED=True,
|
||||
FREEKASSA_MERCHANT_ID="123456",
|
||||
FREEKASSA_API_KEY="api-key",
|
||||
FREEKASSA_SECOND_SECRET="second-secret",
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB",
|
||||
FREEKASSA_PAYMENT_IP="203.0.113.10",
|
||||
FREEKASSA_PAYMENT_METHOD_ID=44,
|
||||
FREEKASSA_TRUSTED_IPS="127.0.0.1,203.0.113.0/24",
|
||||
trusted_proxies=["127.0.0.1"],
|
||||
freekassa_trusted_ips=["127.0.0.1", "203.0.113.0/24"],
|
||||
)
|
||||
return FreeKassaService(
|
||||
bot=object(),
|
||||
settings=settings,
|
||||
i18n=object(),
|
||||
async_session_factory=object(),
|
||||
subscription_service=object(),
|
||||
referral_service=object(),
|
||||
)
|
||||
|
||||
def test_validate_signature_accepts_hmac_sha256_raw_body(self):
|
||||
service = self._make_service()
|
||||
raw_body = b'{"amount":"199.00","o":"42"}'
|
||||
expected_signature = hmac.new(
|
||||
service.second_secret.encode("utf-8"),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
self.assertTrue(service._validate_signature(raw_body, expected_signature))
|
||||
|
||||
def test_validate_signature_rejects_wrong_signature(self):
|
||||
service = self._make_service()
|
||||
|
||||
self.assertFalse(service._validate_signature(b"payload", "not-a-signature"))
|
||||
|
||||
def test_webhook_rejects_unauthorized_ip_before_body_read(self):
|
||||
service = self._make_service()
|
||||
request = SimpleNamespace(
|
||||
remote="198.51.100.250",
|
||||
headers={},
|
||||
read=AsyncMock(side_effect=AssertionError("request.read() must not be called")),
|
||||
)
|
||||
|
||||
response = asyncio_run(service.webhook_route(request))
|
||||
|
||||
self.assertEqual(response.status, 403)
|
||||
request.read.assert_not_awaited()
|
||||
|
||||
|
||||
def asyncio_run(coro):
|
||||
import asyncio
|
||||
|
||||
return asyncio.run(coro)
|
||||
@@ -0,0 +1,28 @@
|
||||
import unittest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
class SettingsTests(unittest.TestCase):
|
||||
def test_blank_postgres_password_is_rejected(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="",
|
||||
)
|
||||
|
||||
def test_webapp_secrets_are_generated_when_missing(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
)
|
||||
|
||||
self.assertTrue(settings.WEBAPP_SESSION_SECRET)
|
||||
self.assertTrue(settings.WEBHOOK_SECRET_TOKEN)
|
||||
self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400)
|
||||
Reference in New Issue
Block a user