security: fix CSRF/ownership/timing vulns; add billing reconciliation
- auth/guard: добавить CSRF-проверку Origin/Referer для cookie-мутаций, исправить инвертированную логику admin_guard (пускал всех, блокировал админов), перевести admin_guard на from_fn_with_state - auth/service: timing-safe сравнение HMAC через verify_slice вместо != по hex; криптостойкий Seed через Alphanumeric (был цифровой 0-9) - billing: confirm_payment проверяет owner (caller_id == invoice.user_id); активация триала только для Telegram-аккаунтов (Sybil-защита); реализована reconcile_pending_payments — сверка blockchain->pending-инвойсы - billing/providers: get_recent_transactions перенесён в трейт PaymentProvider - nodes/service: unwrap() -> map_err по всему SSH-пути; убран TcpStream import - docker: multi-stage build с cargo-chef, non-root user, healthcheck; prod-compose добавляет one-shot migrate-сервис, убирает проброс порта БД - deploy.yml: параметр image_tag, set -e, pull всего стека вместо только app - users/service: обработка NO_TICKETS / NO_USER из БД вместо catch-all Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -115,9 +115,11 @@ impl BillingService {
|
||||
provider.create_checkout_session(&invoice_data).await
|
||||
}
|
||||
|
||||
/// Этап 2: Вебхук или ручная проверка оплаты юзером
|
||||
/// Этап 2: Вебхук или ручная проверка оплаты юзером.
|
||||
/// `caller_id` — кто инициировал подтверждение (для проверки владения инвойсом).
|
||||
pub async fn confirm_payment(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
invoice_id: Uuid,
|
||||
external_tx_id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
@@ -128,6 +130,14 @@ impl BillingService {
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Инвойс не найден".into()))?;
|
||||
|
||||
// Подтверждать инвойс может только его владелец. Иначе чужой юзер мог бы
|
||||
// инициировать обращения к блокчейну по произвольным invoice_id (перебор/DoS).
|
||||
if invoice.user_id != caller_id {
|
||||
return Err(AppError::Unauthorized(
|
||||
"Этот инвойс принадлежит другому оперативнику.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if invoice.status == "paid" {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -169,7 +179,76 @@ impl BillingService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Фоновая сверка блокчейна: находит pending-инвойсы, по которым уже прошла
|
||||
/// оплата на мастер-кошелёк (но вебхук/ручное подтверждение потерялись),
|
||||
/// и доводит их до статуса `paid`. Возвращает число добитых инвойсов.
|
||||
pub async fn reconcile_pending_payments(&self) -> Result<u64, AppError> {
|
||||
let pending = self
|
||||
.repo
|
||||
.get_pending_invoices()
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if pending.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let provider = self
|
||||
.providers
|
||||
.get("TON")
|
||||
.ok_or_else(|| AppError::Internal("TON provider не сконфигурирован".into()))?;
|
||||
|
||||
// Снимок последних транзакций кошелька: comment(invoice_id) -> tx_hash
|
||||
let txs = provider.get_recent_transactions().await?;
|
||||
let mut hash_by_comment: HashMap<String, String> = HashMap::new();
|
||||
for tx in &txs {
|
||||
let comment = tx["in_msg"]["decoded_body"]["text"].as_str().unwrap_or("");
|
||||
let hash = tx["hash"].as_str().unwrap_or("");
|
||||
if !comment.is_empty() && !hash.is_empty() {
|
||||
hash_by_comment
|
||||
.entry(comment.to_string())
|
||||
.or_insert_with(|| hash.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut confirmed = 0u64;
|
||||
for invoice in pending {
|
||||
if let Some(tx_hash) = hash_by_comment.get(&invoice.id.to_string()) {
|
||||
// confirm_payment повторно валидирует сумму/адрес/коммент on-chain,
|
||||
// так что ложное срабатывание по совпадению комментария исключено.
|
||||
// Сверка системная — владельцем выступает сам пользователь инвойса.
|
||||
match self
|
||||
.confirm_payment(invoice.user_id, invoice.id, tx_hash)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
confirmed += 1;
|
||||
tracing::info!(
|
||||
"[{}] Реконсиляция: добит потерянный платёж по инвойсу {}",
|
||||
provider.provider_id(),
|
||||
invoice.id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Реконсиляция инвойса {} не удалась: {:?}", invoice.id, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(confirmed)
|
||||
}
|
||||
|
||||
pub async fn activate_trial(&self, user: &User) -> Result<(), AppError> {
|
||||
// Защита от Sybil-атаки: анонимные Ghost-аккаунты (вход по Seed) генерируются
|
||||
// бесплатно и без лимита, поэтому им триал не положен — иначе любой скрипт
|
||||
// штампует новые Seed и крутит бесконечный бесплатный VPN. Триал привязан к
|
||||
// Telegram-личности (tg_id), которую так дёшево не размножить.
|
||||
if user.tg_id.is_none() {
|
||||
return Err(AppError::BusinessLogic(
|
||||
"Пробный период доступен только для аккаунтов, привязанных к Telegram.".into(),
|
||||
));
|
||||
}
|
||||
if user.has_used_trial {
|
||||
return Err(AppError::TrialAlreadyUsed);
|
||||
}
|
||||
@@ -190,7 +269,7 @@ impl BillingService {
|
||||
fiat_amount_minimal: i64, // В копейках/центах из БД
|
||||
ton_rate_to_currency: rust_decimal::Decimal, // Полный путь до типа, чтобы не было конфликтов имён
|
||||
) -> Result<i64, String> {
|
||||
use rust_decimal::prelude::{ToPrimitive, Zero};
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
// 1. Создаем константу 100 безопасным способом (100.00)
|
||||
|
||||
Reference in New Issue
Block a user