security: harden webhooks and session secrets

This commit is contained in:
3252a8
2026-04-26 19:46:57 +03:00
parent 94b0787cad
commit 77370eb963
17 changed files with 622 additions and 138 deletions
+6
View File
@@ -5,6 +5,12 @@
.gitattributes .gitattributes
LICENSE LICENSE
README.md README.md
.env
.env.*
!.env.example
scratch_*.py
node_modules/
.git/
# CI # CI
+7 -3
View File
@@ -3,8 +3,8 @@ BOT_TOKEN=your_bot_token_here #
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
# PostgreSQL Database Connection Settings # PostgreSQL Database Connection Settings
POSTGRES_USER=postgres # Database user name POSTGRES_USER= # Required: database user name
POSTGRES_PASSWORD=postgres # Database password POSTGRES_PASSWORD= # Required: database password
POSTGRES_HOST=remnawave-tg-shop-db # Database container name POSTGRES_HOST=remnawave-tg-shop-db # Database container name
POSTGRES_PORT=5432 # Port POSTGRES_PORT=5432 # Port
POSTGRES_DB=postgres # Database name 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 (used for Telegram and payment providers)
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld 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) # Subscription Mini App (same container, separate port)
WEBAPP_ENABLED=True # Run Mini App HTTP server WEBAPP_ENABLED=True # Run Mini App HTTP server
@@ -39,7 +40,9 @@ WEBAPP_SERVER_PORT=8081 #
WEBAPP_TITLE="Моя подписка" # Mini App title WEBAPP_TITLE="Моя подписка" # Mini App title
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color 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_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_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime 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_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa 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_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 # CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN= # API token for CryptoPay CRYPTOPAY_TOKEN= # API token for CryptoPay
+5 -1
View File
@@ -3,10 +3,14 @@ bot_database.sqlite3
# Игнорировать файлы окружения # Игнорировать файлы окружения
.env .env
.env.*
!.env.example
scratch_*.py
node_modules/
.git/
# Игнорировать кэш Python # Игнорировать кэш Python
__pycache__/ __pycache__/
node_modules/
# Игнорировать настройки IDE (если используешь, например, PyCharm или VSCode) # Игнорировать настройки IDE (если используешь, например, PyCharm или VSCode)
.idea/ .idea/
+14 -2
View File
@@ -1,3 +1,4 @@
import hmac
import asyncio import asyncio
import logging import logging
from contextlib import suppress from contextlib import suppress
@@ -9,6 +10,13 @@ from sqlalchemy.orm import sessionmaker
from config.settings import Settings 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 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) telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
if telegram_uses_webhook_mode: if telegram_uses_webhook_mode:
telegram_webhook_path = f"/{settings.BOT_TOKEN}" telegram_webhook_path = settings.telegram_webhook_path
app.router.add_post(telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot)) SecureSimpleRequestHandler(
dispatcher=dp,
bot=bot,
secret_token=settings.WEBHOOK_SECRET_TOKEN,
).register(app, path=telegram_webhook_path)
logging.info( logging.info(
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
) )
+1 -1
View File
@@ -23,7 +23,7 @@ def _urlsafe_b64decode(raw: str) -> bytes:
def _session_secret(settings: Settings) -> bytes: def _session_secret(settings: Settings) -> bytes:
return hmac.new( return hmac.new(
settings.BOT_TOKEN.encode("utf-8"), settings.WEBAPP_SESSION_SECRET.encode("utf-8"),
b"remnawave-tg-shop-webapp-session", b"remnawave-tg-shop-webapp-session",
hashlib.sha256, hashlib.sha256,
).digest() ).digest()
+15
View File
@@ -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.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.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links 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() payment_processing_lock = asyncio.Lock()
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded' YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled' YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture' 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, async def process_successful_payment(session: AsyncSession, bot: Bot,
@@ -484,6 +494,11 @@ async def yookassa_webhook_route(request: web.Request):
status=500, status=500,
text="Internal Server Error: Missing app context component") 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: try:
event_json = await request.json() event_json = await request.json()
+50 -50
View File
@@ -40,6 +40,12 @@ from bot.handlers.admin.sync_admin import perform_sync
from bot.utils.message_queue import init_queue_manager 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): async def register_all_routers(dp: Dispatcher, settings: Settings):
dp.include_router(build_root_router(settings)) dp.include_router(build_root_router(settings))
logging.info("All application routers registered.") 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 telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
if telegram_webhook_url_to_set: if telegram_webhook_url_to_set:
full_telegram_webhook_url = ( 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( 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:
try: current_webhook_info = await bot.get_webhook_info()
current_webhook_info = await bot.get_webhook_info() logging.info(
logging.info( f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
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."
) )
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: else:
logging.error( logging.error(
"STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting." "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( logging.info(
"STARTUP: Mini app domain registered and default menu button restored." "STARTUP: Mini app domain registered and default menu button restored."
) )
except Exception as e: except Exception:
logging.error( logging.exception("STARTUP: Failed to register mini app domain.")
f"STARTUP: Failed to register mini app domain: {e}", exc_info=True
)
try: try:
bot_commands = [ bot_commands = [
@@ -144,16 +144,16 @@ async def on_startup_configured(dispatcher: Dispatcher):
) )
await bot.set_my_commands(bot_commands) await bot.set_my_commands(bot_commands)
logging.info("STARTUP: bot command descriptions set.") logging.info("STARTUP: bot command descriptions set.")
except Exception as e: except Exception:
logging.error(f"STARTUP: Failed to set bot commands: {e}", exc_info=True) logging.exception("STARTUP: Failed to set bot commands.")
# Initialize message queue manager # Initialize message queue manager
try: try:
queue_manager = init_queue_manager(bot) queue_manager = init_queue_manager(bot)
dispatcher["queue_manager"] = queue_manager dispatcher["queue_manager"] = queue_manager
logging.info("STARTUP: Message queue manager initialized") logging.info("STARTUP: Message queue manager initialized")
except Exception as e: except Exception:
logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True) logging.exception("STARTUP: Failed to initialize message queue manager.")
# Automatic sync on startup # Automatic sync on startup
try: try:
@@ -172,8 +172,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
else: else:
logging.warning(f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}") logging.warning(f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}")
except Exception as e: except Exception:
logging.error(f"STARTUP: Failed to run automatic sync: {e}", exc_info=True) logging.exception("STARTUP: Failed to run automatic sync.")
logging.info("STARTUP: Bot on_startup_configured completed.") logging.info("STARTUP: Bot on_startup_configured completed.")
+16 -1
View File
@@ -12,7 +12,7 @@ from email.message import EmailMessage
from email.utils import formataddr from email.utils import formataddr
from typing import Optional from typing import Optional
from sqlalchemy import select from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings from config.settings import Settings
@@ -145,6 +145,18 @@ class EmailAuthService:
retry_after=resend_after - elapsed, 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 = f"{secrets.randbelow(1_000_000):06d}"
code_model = EmailVerificationCode( code_model = EmailVerificationCode(
email=normalized_email, email=normalized_email,
@@ -152,6 +164,7 @@ class EmailAuthService:
purpose=purpose, purpose=purpose,
target_user_id=target_user_id, target_user_id=target_user_id,
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))), expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
status="active",
) )
session.add(code_model) session.add(code_model)
await session.flush() await session.flush()
@@ -281,6 +294,8 @@ class EmailAuthService:
EmailVerificationCode.email == email, EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose, EmailVerificationCode.purpose == purpose,
EmailVerificationCode.target_user_id == target_user_id, EmailVerificationCode.target_user_id == target_user_id,
EmailVerificationCode.status == "active",
EmailVerificationCode.consumed_at.is_(None),
) )
.order_by(EmailVerificationCode.created_at.desc()) .order_by(EmailVerificationCode.created_at.desc())
.limit(1) .limit(1)
+30 -40
View File
@@ -7,6 +7,7 @@ import logging
import time import time
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Dict, Any, Tuple from typing import Optional, Dict, Any, Tuple
from urllib.parse import parse_qsl
from aiohttp import ClientSession, ClientTimeout, web from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot from aiogram import Bot
@@ -21,6 +22,7 @@ from bot.services.notification_service import NotificationService
from db.dal import payment_dal, user_dal from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
class FreeKassaService: class FreeKassaService:
@@ -169,69 +171,59 @@ class FreeKassaService:
def _validate_signature( def _validate_signature(
self, self,
merchant_order_id: str, raw_body: bytes,
amount: str,
provided_signature: str, provided_signature: str,
payload: Optional[Dict[str, Any]] = None,
) -> bool: ) -> bool:
if not provided_signature: if not provided_signature:
return False return False
if not self.second_secret:
return False
if self.shop_id and self.second_secret: expected_signature = hmac.new(
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}" self.second_secret.encode("utf-8"),
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest() raw_body,
if expected_signature.lower() == provided_signature.lower(): hashlib.sha256,
return True ).hexdigest()
return hmac.compare_digest(expected_signature, provided_signature)
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
async def webhook_route(self, request: web.Request) -> web.Response: async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured: if not self.configured:
return web.Response(status=503, text="freekassa_disabled") return web.Response(status=503, text="freekassa_disabled")
try: 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: 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") return web.Response(status=400, text="bad_request")
payload_dict: Dict[str, Any] payload_dict: Dict[str, Any] = {}
if data: if raw_body:
payload_dict = {str(k): v for k, v in data.items()}
else:
try: try:
json_payload = await request.json() if request.content_type.startswith("application/json"):
payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {} decoded_json = json.loads(raw_body.decode("utf-8"))
data = json_payload 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: except Exception:
payload_dict = {} payload_dict = {}
data = {}
def _get(key: str, default: Optional[str] = None) -> Optional[str]: 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 return payload_dict.get(key) or payload_dict.get(key.lower()) or default
merchant_id = _get("MERCHANT_ID") merchant_id = _get("MERCHANT_ID")
if merchant_id != self.shop_id: if merchant_id != self.shop_id:
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})") return web.Response(status=403)
return web.Response(status=403, text="merchant_mismatch")
signature = _get("SIGN") or _get("signature") signature = _get("SIGN") or _get("signature")
if not signature: if not signature:
logging.error("FreeKassa webhook: missing signature")
return web.Response(status=400, text="missing_signature") return web.Response(status=400, text="missing_signature")
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o") 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") provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
if not order_id_str or not amount_str: 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") return web.Response(status=400, text="missing_data")
if not self._validate_signature(order_id_str, amount_str, signature, payload_dict): if not self._validate_signature(raw_body, signature):
logging.error("FreeKassa webhook: invalid signature")
return web.Response(status=403, text="invalid_signature") return web.Response(status=403, text="invalid_signature")
try: try:
+19 -12
View File
@@ -26,6 +26,10 @@ class PanelWebhookService:
self.i18n = i18n self.i18n = i18n
self.async_session_factory = async_session_factory self.async_session_factory = async_session_factory
self.panel_service = panel_service 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( async def _send_message(
self, self,
@@ -40,8 +44,8 @@ class PanelWebhookService:
await self.bot.send_message( await self.bot.send_message(
user_id, _(message_key, **kwargs), reply_markup=reply_markup user_id, _(message_key, **kwargs), reply_markup=reply_markup
) )
except Exception as e: except Exception:
logging.error(f"Failed to send notification to {user_id}: {e}") logging.exception("Failed to send notification to %s", user_id)
async def handle_event(self, event_name: str, user_payload: dict): async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId") 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: async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
if self.settings.PANEL_WEBHOOK_SECRET: if not self.settings.PANEL_WEBHOOK_SECRET:
if not signature_header: return web.Response(status=401, text="unauthorized")
return web.Response(status=403, text="no_signature")
expected_sig = hmac.new( if not signature_header:
self.settings.PANEL_WEBHOOK_SECRET.encode(), return web.Response(status=401, text="unauthorized")
raw_body,
hashlib.sha256, expected_sig = hmac.new(
).hexdigest() self.settings.PANEL_WEBHOOK_SECRET.encode(),
if not hmac.compare_digest(expected_sig, signature_header): raw_body,
return web.Response(status=403, text="invalid_signature") hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_sig, signature_header):
return web.Response(status=401, text="unauthorized")
try: try:
payload = json.loads(raw_body.decode()) payload = json.loads(raw_body.decode())
+5 -1
View File
@@ -1,3 +1,4 @@
import hmac
import json import json
import logging import logging
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
@@ -141,7 +142,10 @@ class PlategaService:
header_merchant = request.headers.get("X-MerchantId") header_merchant = request.headers.get("X-MerchantId")
header_secret = request.headers.get("X-Secret") 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") logging.error("Platega webhook: invalid auth headers")
return web.Response(status=403, text="forbidden") return web.Response(status=403, text="forbidden")
+10 -9
View File
@@ -161,10 +161,11 @@ class YooKassaService:
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}" f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
) )
loop = asyncio.get_running_loop() response = await asyncio.to_thread(
response = await loop.run_in_executor( YooKassaPayment.create,
None, lambda: YooKassaPayment.create(payment_request, payment_request,
idempotence_key)) idempotence_key,
)
logging.info( logging.info(
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}" 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}" f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
) )
loop = asyncio.get_running_loop() payment_info_yk = await asyncio.to_thread(
payment_info_yk = await loop.run_in_executor( YooKassaPayment.find_one,
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa)) payment_id_in_yookassa,
)
if payment_info_yk: if payment_info_yk:
logging.info( logging.info(
@@ -275,8 +277,7 @@ class YooKassaService:
logging.error("YooKassa is not configured. Cannot cancel payment.") logging.error("YooKassa is not configured. Cannot cancel payment.")
return False return False
try: try:
loop = asyncio.get_running_loop() await asyncio.to_thread(YooKassaPayment.cancel, payment_id_in_yookassa)
await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa))
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}") logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
return True return True
except Exception as e: except Exception as e:
+73
View File
@@ -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
View File
@@ -1,8 +1,97 @@
import logging import logging
from pydantic_settings import BaseSettings, SettingsConfigDict import os
from pydantic import Field, ValidationError, computed_field, field_validator import secrets
from typing import Optional, List, Dict, Any 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): class Settings(BaseSettings):
BOT_TOKEN: str BOT_TOKEN: str
@@ -11,8 +100,8 @@ class Settings(BaseSettings):
alias="ADMIN_IDS", alias="ADMIN_IDS",
description="Comma-separated list of admin Telegram User IDs") description="Comma-separated list of admin Telegram User IDs")
POSTGRES_USER: str = Field(default="user") POSTGRES_USER: str = Field(...)
POSTGRES_PASSWORD: str = Field(default="password") POSTGRES_PASSWORD: str = Field(...)
POSTGRES_HOST: str = Field(default="localhost") POSTGRES_HOST: str = Field(default="localhost")
POSTGRES_PORT: int = Field(default=5432) POSTGRES_PORT: int = Field(default=5432)
POSTGRES_DB: str = Field(default="vpn_shop_db") POSTGRES_DB: str = Field(default="vpn_shop_db")
@@ -75,6 +164,10 @@ class Settings(BaseSettings):
) )
WEBHOOK_BASE_URL: Optional[str] = None 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_TOKEN: Optional[str] = None
CRYPTOPAY_NETWORK: str = Field(default="mainnet") CRYPTOPAY_NETWORK: str = Field(default="mainnet")
@@ -99,6 +192,10 @@ class Settings(BaseSettings):
FREEKASSA_API_KEY: Optional[str] = None FREEKASSA_API_KEY: Optional[str] = None
FREEKASSA_PAYMENT_IP: Optional[str] = None FREEKASSA_PAYMENT_IP: Optional[str] = None
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = 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_ENABLED: bool = Field(default=False)
SEVERPAY_MID: Optional[int] = None SEVERPAY_MID: Optional[int] = None
@@ -211,7 +308,9 @@ class Settings(BaseSettings):
WEBAPP_TITLE: str = Field(default="Моя подписка") WEBAPP_TITLE: str = Field(default="Моя подписка")
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a") WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
WEBAPP_LOGO_URL: Optional[str] = Field(default=None) 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_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60) WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
@@ -268,6 +367,98 @@ class Settings(BaseSettings):
def DATABASE_URL(self) -> str: def DATABASE_URL(self) -> str:
return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" 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 @computed_field
@property @property
def ADMIN_IDS(self) -> List[int]: def ADMIN_IDS(self) -> List[int]:
@@ -325,6 +516,21 @@ class Settings(BaseSettings):
return cleaned return cleaned
return None 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 @computed_field
@property @property
def yookassa_webhook_path(self) -> str: def yookassa_webhook_path(self) -> str:
@@ -605,6 +811,26 @@ class Settings(BaseSettings):
return "INFO" return "INFO"
return v 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') @field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
@classmethod @classmethod
def validate_optional_int_fields(cls, v): def validate_optional_int_fields(cls, v):
@@ -675,6 +901,14 @@ def get_settings() -> Settings:
logging.warning( logging.warning(
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work." "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: if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY:
logging.warning( logging.warning(
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work." "CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
+1 -13
View File
@@ -11,22 +11,10 @@ from db.database_setup import init_db, init_db_connection
def _resolve_log_level(value: str) -> int: def _resolve_log_level(value: str) -> int:
if not value: return getattr(logging, value.upper(), logging.INFO)
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
async def main(): async def main():
load_dotenv()
settings = get_settings() settings = get_settings()
session_factory = init_db_connection(settings) session_factory = init_db_connection(settings)
+103
View File
@@ -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)
+28
View File
@@ -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)