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:
+53
-19
@@ -1,37 +1,68 @@
|
||||
use crate::{api::ApiState, db::models::User, error::AppError};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::Method,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
/// Проверяет JWT и инжектит User в запрос.
|
||||
/// Аналог AuthGuard в NestJS.
|
||||
/// Проверяет JWT и инжектит User в запрос. Добавлена базовая защита от CSRF.
|
||||
pub async fn auth_guard(
|
||||
State(state): State<ApiState>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let auth_header = req
|
||||
let method = req.method().clone();
|
||||
let is_mutating = matches!(
|
||||
method,
|
||||
Method::POST | Method::DELETE | Method::PUT | Method::PATCH
|
||||
);
|
||||
|
||||
// Определяем источник токена. Bearer в заголовке Authorization атакующий не может
|
||||
// выставить из чужого origin, поэтому CSRF ему не грозит. CSRF опасен только для
|
||||
// cookie-аутентификации, которую браузер подставляет автоматически.
|
||||
let bearer = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok());
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.strip_prefix("Bearer "))
|
||||
.map(|t| t.to_string());
|
||||
|
||||
// 1. Получаем чистый токен (String)
|
||||
let token = if let Some(h) = auth_header {
|
||||
h.replace("Bearer ", "")
|
||||
let (token, from_cookie) = if let Some(t) = bearer {
|
||||
(t, false)
|
||||
} else {
|
||||
req.headers()
|
||||
let cookie_token = req
|
||||
.headers()
|
||||
.get("Cookie")
|
||||
.and_then(|c| c.to_str().ok())
|
||||
.and_then(|c| c.split("; ").find(|part| part.starts_with("nrxp_token=")))
|
||||
.map(|part| part.replace("nrxp_token=", ""))
|
||||
.ok_or_else(|| AppError::Unauthorized("Access token required".into()))?
|
||||
.ok_or_else(|| AppError::Unauthorized("Access token required".into()))?;
|
||||
(cookie_token, true)
|
||||
};
|
||||
|
||||
// 2. Декодируем токен, используя полученную выше переменную `token`
|
||||
// Передаем ссылку &token, так как jsonwebtoken ожидает &str
|
||||
// CSRF-защита нужна только для cookie-сессий и только на мутирующих методах.
|
||||
if from_cookie && is_mutating {
|
||||
let origin = req.headers().get("Origin").and_then(|v| v.to_str().ok());
|
||||
let referer = req.headers().get("Referer").and_then(|v| v.to_str().ok());
|
||||
|
||||
let is_valid_origin = origin == Some("https://netrunner-vpn.com");
|
||||
let is_valid_referer = referer
|
||||
.map(|r| r.starts_with("https://netrunner-vpn.com"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_valid_origin && !is_valid_referer {
|
||||
warn!(
|
||||
"CSRF BLOCK: Request rejected. Origin: {:?}, Referer: {:?}",
|
||||
origin, referer
|
||||
);
|
||||
return Err(AppError::Unauthorized(
|
||||
"Invalid Origin/Referer (CSRF protection)".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let token_data = jsonwebtoken::decode::<super::service::Claims>(
|
||||
&token,
|
||||
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||
@@ -53,23 +84,26 @@ pub async fn auth_guard(
|
||||
}
|
||||
|
||||
/// Проверяет права администратора.
|
||||
/// Должен идти строго после auth_guard.
|
||||
pub async fn admin_guard(req: Request, next: Next) -> Result<Response, AppError> {
|
||||
pub async fn admin_guard(
|
||||
State(state): State<ApiState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let user = req
|
||||
.extensions()
|
||||
.get::<User>()
|
||||
.ok_or_else(|| AppError::Unauthorized("Context lost: Auth required".into()))?;
|
||||
|
||||
let admin_id: i64 = std::env::var("ADMIN_TG_ID").unwrap().parse().unwrap();
|
||||
if user.tg_id == Some(admin_id) || user.role == "admin" {
|
||||
// Используем закешированный в памяти ID
|
||||
if user.tg_id == Some(state.admin_tg_id) || user.role == "admin" {
|
||||
Ok(next.run(req).await)
|
||||
} else {
|
||||
warn!(
|
||||
"SECURITY_ALERT: Unauthorized admin access attempt by @{:?}",
|
||||
user.tg_username
|
||||
);
|
||||
return Err(AppError::Unauthorized(
|
||||
Err(AppError::Unauthorized(
|
||||
"Insufficient clearance level".into(),
|
||||
));
|
||||
))
|
||||
}
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user