diff --git a/.dockerignore b/.dockerignore index 7662200..f59b60a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,12 @@ .gitattributes LICENSE README.md +.env +.env.* +!.env.example +scratch_*.py +node_modules/ +.git/ # CI diff --git a/.env.example b/.env.example index 9ede870..f3e4851 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 3103929..66bdacc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,14 @@ bot_database.sqlite3 # Игнорировать файлы окружения .env +.env.* +!.env.example +scratch_*.py +node_modules/ +.git/ # Игнорировать кэш Python __pycache__/ -node_modules/ # Игнорировать настройки IDE (если используешь, например, PyCharm или VSCode) .idea/ diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index 9ca2aeb..ca5365f 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -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)" ) diff --git a/bot/app/web/webapp_auth.py b/bot/app/web/webapp_auth.py index 29c2b25..a64f582 100644 --- a/bot/app/web/webapp_auth.py +++ b/bot/app/web/webapp_auth.py @@ -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() diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index d489379..d00c9f1 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -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() diff --git a/bot/main_bot.py b/bot/main_bot.py index c53a7d9..9416b78 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -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.") diff --git a/bot/services/email_auth_service.py b/bot/services/email_auth_service.py index 97f502b..2c55f4f 100644 --- a/bot/services/email_auth_service.py +++ b/bot/services/email_auth_service.py @@ -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) diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index 5ca9fe4..617b251 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -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: diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index c06f64f..6feefd2 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -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()) diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py index 7fc91f5..71453d4 100644 --- a/bot/services/platega_service.py +++ b/bot/services/platega_service.py @@ -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") diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index e3f25d9..d8d3c70 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -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: diff --git a/bot/utils/request_security.py b/bot/utils/request_security.py new file mode 100644 index 0000000..9883bff --- /dev/null +++ b/bot/utils/request_security.py @@ -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) diff --git a/config/settings.py b/config/settings.py index fe3b590..e7cf0a7 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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." diff --git a/main.py b/main.py index 9bff597..0b14373 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..e5e31de --- /dev/null +++ b/tests/test_security.py @@ -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) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..ea4d01d --- /dev/null +++ b/tests/test_settings.py @@ -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)