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
+16 -1
View File
@@ -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)
+30 -40
View File
@@ -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:
+19 -12
View File
@@ -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())
+5 -1
View File
@@ -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")
+10 -9
View File
@@ -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: