Skip to content

Безопасность в Axum-приложениях

Безопасность является критически важным аспектом любого веб-приложения. В этом руководстве рассмотрены основные практики защиты приложений на Axum от распространенных уязвимостей.

Содержание

Аутентификация и авторизация

JWT аутентификация

rust
use axum::{
    routing::{get, post},
    Router,
    Json,
    extract::{State, TypedHeader},
    headers::{Authorization, authorization::Bearer},
};
use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

// Структура для данных JWT токена
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
    sub: String,
    exp: usize,
    iat: usize,
    role: String,
}

// Middleware для проверки JWT токена
async fn auth_middleware<B>(
    TypedHeader(authorization): TypedHeader<Authorization<Bearer>>,
    request: Request<B>,
    next: Next<B>,
) -> Result<Response, StatusCode> {
    // Извлечение токена из заголовка
    let token = authorization.token();
    
    // Проверка токена
    let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
    let key = DecodingKey::from_secret(jwt_secret.as_bytes());
    
    match decode::<Claims>(token, &key, &Validation::default()) {
        Ok(token_data) => {
            // Добавляем данные пользователя в расширения запроса
            let mut request = request;
            request.extensions_mut().insert(token_data.claims);
            
            // Пропускаем запрос дальше
            Ok(next.run(request).await)
        },
        Err(_) => {
            Err(StatusCode::UNAUTHORIZED)
        }
    }
}

// Авторизация на основе ролей
async fn role_middleware<B>(
    request: Request<B>,
    next: Next<B>,
) -> Result<Response, StatusCode> {
    // Получаем данные пользователя из расширений запроса
    let claims = request.extensions().get::<Claims>()
        .ok_or(StatusCode::UNAUTHORIZED)?;
    
    // Проверяем роль
    if claims.role != "admin" {
        return Err(StatusCode::FORBIDDEN);
    }
    
    // Пропускаем запрос дальше
    Ok(next.run(request).await)
}

// Функция для создания JWT токена
fn create_jwt(user_id: &str, role: &str) -> Result<String, jsonwebtoken::errors::Error> {
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as usize;
    let claims = Claims {
        sub: user_id.to_string(),
        exp: now + 3600, // Срок действия 1 час
        iat: now,
        role: role.to_string(),
    };
    
    let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
    encode(&Header::default(), &claims, &EncodingKey::from_secret(jwt_secret.as_bytes()))
}

// Применение middleware в маршрутах
let app = Router::new()
    .route("/login", post(login_handler))
    .route(
        "/api/protected",
        get(protected_handler)
            .route_layer(middleware::from_fn(auth_middleware))
    )
    .route(
        "/api/admin",
        get(admin_handler)
            .route_layer(middleware::from_fn(role_middleware))
            .route_layer(middleware::from_fn(auth_middleware))
    );

Хранение паролей

rust
use argon2::{
    password_hash::{
        rand_core::OsRng,
        PasswordHash, PasswordHasher, PasswordVerifier, SaltString
    },
    Argon2
};

// Хеширование пароля
async fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
    // Используем spawn_blocking, так как хеширование - CPU-интенсивная операция
    tokio::task::spawn_blocking(move || {
        let salt = SaltString::generate(&mut OsRng);
        let argon2 = Argon2::default();
        
        let password_hash = argon2.hash_password(password.as_bytes(), &salt)?
            .to_string();
            
        Ok(password_hash)
    }).await.unwrap()
}

// Проверка пароля
async fn verify_password(
    password: &str, 
    password_hash: &str
) -> Result<bool, argon2::password_hash::Error> {
    tokio::task::spawn_blocking(move || {
        let parsed_hash = PasswordHash::new(password_hash)?;
        let argon2 = Argon2::default();
        
        match argon2.verify_password(password.as_bytes(), &parsed_hash) {
            Ok(()) => Ok(true),
            Err(argon2::password_hash::Error::Password) => Ok(false),
            Err(e) => Err(e),
        }
    }).await.unwrap()
}

Защита от CSRF

Реализация CSRF токенов

rust
use axum::{
    extract::{Extension, TypedHeader, Form},
    headers::{Cookie, SetCookie},
    response::{IntoResponse, Response},
    routing::get,
    Router,
};
use tower_cookies::{CookieManagerLayer, Cookies};
use rand::{thread_rng, Rng};
use serde::Deserialize;

// Генерация CSRF токена
fn generate_csrf_token() -> String {
    let mut rng = thread_rng();
    (0..32)
        .map(|_| rng.sample(rand::distributions::Alphanumeric) as char)
        .collect()
}

// Middleware для проверки CSRF токена
async fn csrf_protection<B>(
    cookies: Cookies,
    TypedHeader(content_type): TypedHeader<headers::ContentType>,
    request: Request<B>,
    next: Next<B>,
) -> Result<Response, StatusCode> {
    // Проверяем только для POST/PUT/DELETE запросов
    if request.method() == Method::POST || 
       request.method() == Method::PUT || 
       request.method() == Method::DELETE {
        
        // Получаем CSRF токен из cookie
        let csrf_cookie = cookies.get("csrf_token")
            .ok_or(StatusCode::FORBIDDEN)?;
            
        // Проверяем, что заголовок X-CSRF-Token совпадает с cookie
        let csrf_header = request.headers()
            .get("X-CSRF-Token")
            .and_then(|h| h.to_str().ok())
            .ok_or(StatusCode::FORBIDDEN)?;
            
        if csrf_cookie.value() != csrf_header {
            return Err(StatusCode::FORBIDDEN);
        }
    }
    
    Ok(next.run(request).await)
}

// Обработчик для установки CSRF токена
async fn set_csrf(cookies: Cookies) -> impl IntoResponse {
    let token = generate_csrf_token();
    
    // Устанавливаем CSRF токен в cookie
    cookies.add(Cookie::build("csrf_token", token.clone())
        .http_only(true)
        .secure(true)
        .path("/")
        .same_site(tower_cookies::cookie::SameSite::Strict)
        .build());
    
    // Возвращаем токен в ответе для использования в JavaScript
    Json(json!({ "csrf_token": token }))
}

// Применение в маршрутах
let app = Router::new()
    .route("/csrf-token", get(set_csrf))
    .route_layer(middleware::from_fn(csrf_protection))
    .layer(CookieManagerLayer::new());

Предотвращение XSS

Экранирование вывода

rust
use html_escape::encode_text;

// Экранирование HTML-сущностей
fn safe_html(text: &str) -> String {
    encode_text(text)
}

// Middleware для установки заголовков безопасности
async fn security_headers<B>(
    request: Request<B>,
    next: Next<B>,
) -> Response {
    let mut response = next.run(request).await;
    
    // Устанавливаем заголовки для защиты от XSS
    let headers = response.headers_mut();
    headers.insert(
        "Content-Security-Policy",
        "default-src 'self'; script-src 'self'; object-src 'none'".parse().unwrap()
    );
    headers.insert("X-XSS-Protection", "1; mode=block".parse().unwrap());
    headers.insert("X-Content-Type-Options", "nosniff".parse().unwrap());
    
    response
}

SQL-инъекции

Использование параметризованных запросов

rust
use sqlx::{PgPool, FromRow};

#[derive(FromRow)]
struct User {
    id: i32,
    username: String,
    email: String,
}

// Правильно: использование параметризованных запросов
async fn get_user_safe(pool: &PgPool, username: &str) -> Result<User, sqlx::Error> {
    sqlx::query_as::<_, User>("SELECT id, username, email FROM users WHERE username = $1")
        .bind(username)
        .fetch_one(pool)
        .await
}

// Неправильно: прямая конкатенация строк (УЯЗВИМО!)
async fn get_user_unsafe(pool: &PgPool, username: &str) -> Result<User, sqlx::Error> {
    let query = format!("SELECT id, username, email FROM users WHERE username = '{}'", username);
    
    // НЕБЕЗОПАСНО - НЕ ИСПОЛЬЗУЙТЕ ТАКОЙ ПОДХОД
    sqlx::query_as::<_, User>(&query)
        .fetch_one(pool)
        .await
}

Безопасные заголовки

Middleware для заголовков безопасности

rust
use axum::{
    middleware::{self, Next},
    response::Response,
    Router,
};
use tower_http::set_header::SetResponseHeaderLayer;
use http::{HeaderName, HeaderValue};

// Применение заголовков безопасности
let app = Router::new()
    // Заголовки от XSS
    .layer(SetResponseHeaderLayer::if_not_present(
        HeaderName::from_static("x-xss-protection"),
        HeaderValue::from_static("1; mode=block"),
    ))
    .layer(SetResponseHeaderLayer::if_not_present(
        HeaderName::from_static("x-content-type-options"),
        HeaderValue::from_static("nosniff"),
    ))
    // Предотвращение кликджекинга
    .layer(SetResponseHeaderLayer::if_not_present(
        HeaderName::from_static("x-frame-options"),
        HeaderValue::from_static("DENY"),
    ))
    // Content Security Policy
    .layer(SetResponseHeaderLayer::if_not_present(
        HeaderName::from_static("content-security-policy"),
        HeaderValue::from_static("default-src 'self'; script-src 'self'"),
    ))
    // HSTS
    .layer(SetResponseHeaderLayer::if_not_present(
        HeaderName::from_static("strict-transport-security"),
        HeaderValue::from_static("max-age=31536000; includeSubDomains"),
    ));

Ограничение скорости запросов

Реализация Rate Limiting

rust
use std::sync::Arc;
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use axum::{
    extract::ConnectInfo,
    middleware::{self, Next},
    response::Response,
    http::StatusCode,
    Router,
};

// Состояние для rate limiting
#[derive(Debug, Clone, Default)]
struct RateLimiter {
    windows: Arc<Mutex<HashMap<IpAddr, Vec<Instant>>>>,
    max_requests: usize,
    window_size: Duration,
}

impl RateLimiter {
    fn new(max_requests: usize, window_size: Duration) -> Self {
        Self {
            windows: Arc::new(Mutex::new(HashMap::new())),
            max_requests,
            window_size,
        }
    }
    
    async fn check(&self, ip: IpAddr) -> bool {
        let mut windows = self.windows.lock().await;
        let now = Instant::now();
        
        // Получаем или создаем список временных меток для IP
        let window = windows.entry(ip).or_insert_with(Vec::new);
        
        // Удаляем устаревшие записи
        window.retain(|time| now.duration_since(*time) < self.window_size);
        
        // Проверяем, не превышен ли лимит
        if window.len() >= self.max_requests {
            return false;
        }
        
        // Добавляем новую временную метку
        window.push(now);
        true
    }
}

// Middleware для rate limiting
async fn rate_limit<B>(
    ConnectInfo(addr): ConnectInfo<std::net::SocketAddr>,
    State(limiter): State<RateLimiter>,
    request: Request<B>,
    next: Next<B>,
) -> Result<Response, StatusCode> {
    // Проверяем лимит для IP-адреса
    if !limiter.check(addr.ip()).await {
        return Err(StatusCode::TOO_MANY_REQUESTS);
    }
    
    // Пропускаем запрос
    Ok(next.run(request).await)
}

// Применение в приложении
let limiter = RateLimiter::new(100, Duration::from_secs(60)); // 100 запросов в минуту

let app = Router::new()
    .route_layer(middleware::from_fn_with_state(limiter, rate_limit))
    .with_state(limiter);

Валидация входных данных

Проверка входных данных с помощью validator

rust
use axum::{
    extract::Json,
    response::{IntoResponse, Response},
    http::StatusCode,
};
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};

#[derive(Debug, Deserialize, Validate)]
struct CreateUser {
    #[validate(length(min = 3, max = 50))]
    username: String,
    
    #[validate(email)]
    email: String,
    
    #[validate(length(min = 8), custom = "validate_password")]
    password: String,
}

// Кастомная валидация пароля
fn validate_password(password: &str) -> Result<(), ValidationError> {
    // Проверка наличия цифр
    if !password.chars().any(|c| c.is_digit(10)) {
        let mut err = ValidationError::new("password_missing_number");
        err.message = Some("Пароль должен содержать хотя бы одну цифру".into());
        return Err(err);
    }
    
    // Проверка наличия специальных символов
    if !password.chars().any(|c| !c.is_alphanumeric()) {
        let mut err = ValidationError::new("password_missing_special");
        err.message = Some("Пароль должен содержать хотя бы один специальный символ".into());
        return Err(err);
    }
    
    Ok(())
}

// Обработчик с валидацией входных данных
async fn create_user(
    Json(payload): Json<CreateUser>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
    // Проверка входных данных
    if let Err(errors) = payload.validate() {
        let error_message = errors
            .field_errors()
            .iter()
            .map(|(field, errors)| {
                let error_messages: Vec<String> = errors
                    .iter()
                    .map(|error| error.message.clone().unwrap_or_else(|| format!("Ошибка в поле {}", field)))
                    .collect();
                format!("{}: {}", field, error_messages.join(", "))
            })
            .collect::<Vec<String>>()
            .join("; ");
            
        return Err((StatusCode::BAD_REQUEST, error_message));
    }
    
    // Продолжение обработки...
    Ok(StatusCode::CREATED)
}

Обработка ошибок

Безопасное логирование и скрытие конфиденциальной информации

rust
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
};
use thiserror::Error;
use uuid::Uuid;
use tracing::{error, info};

#[derive(Debug, Error)]
enum AppError {
    #[error("Database error")]
    DatabaseError(#[from] sqlx::Error),
    
    #[error("Authentication error")]
    AuthError,
    
    #[error("Not found")]
    NotFound,
    
    #[error("Internal server error")]
    InternalError(#[source] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        // Генерируем уникальный идентификатор ошибки
        let error_id = Uuid::new_v4();
        
        // Логируем детали ошибки
        match &self {
            AppError::DatabaseError(err) => {
                error!(error_id = %error_id, error = %err, "Database error occurred");
            },
            AppError::AuthError => {
                info!(error_id = %error_id, "Authentication failed");
            },
            AppError::NotFound => {
                info!(error_id = %error_id, "Resource not found");
            },
            AppError::InternalError(err) => {
                error!(error_id = %error_id, error = %err, "Internal server error");
            },
        }
        
        // Возвращаем клиенту ограниченную информацию об ошибке
        let (status, message) = match self {
            AppError::DatabaseError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"),
            AppError::AuthError => (StatusCode::UNAUTHORIZED, "Authentication failed"),
            AppError::NotFound => (StatusCode::NOT_FOUND, "Resource not found"),
            AppError::InternalError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"),
        };
        
        // Возвращаем клиенту идентификатор ошибки для возможности поиска в логах
        let body = serde_json::json!({
            "error": message,
            "error_id": error_id.to_string(),
        });
        
        (status, axum::Json(body)).into_response()
    }
}

CORS

Настройка CORS с помощью tower-http

rust
use axum::Router;
use tower_http::cors::{CorsLayer, Origin, Permission};

// Базовая настройка CORS
let cors = CorsLayer::new()
    // Разрешаем запросы с определенных доменов
    .allow_origin(["https://example.com", "https://app.example.com"].map(|o| o.parse::<HeaderValue>().unwrap()))
    // Разрешаем определенные методы
    .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
    // Разрешаем заголовки
    .allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION])
    // Разрешаем отправлять cookies
    .allow_credentials(true);

// Применяем CORS к маршрутизатору
let app = Router::new()
    // Маршруты приложения
    .layer(cors);

Защита от DoS

Ограничение размера тела запроса

rust
use axum::Router;
use tower_http::limit::RequestBodyLimitLayer;

// Ограничиваем размер тела запроса до 1 МБ
let app = Router::new()
    // Маршруты приложения
    .layer(RequestBodyLimitLayer::new(1024 * 1024)); // 1 МБ

Таймауты для запросов

rust
use axum::Router;
use tower::timeout::TimeoutLayer;
use std::time::Duration;

// Устанавливаем таймаут на обработку запроса
let app = Router::new()
    // Маршруты приложения
    .layer(TimeoutLayer::new(Duration::from_secs(30))); // 30 секунд

Лучшие практики

1. Используйте HTTPS

Всегда настраивайте TLS для production-окружения:

rust
use axum::Router;
use tokio::net::TcpListener;
use axum_server::tls_rustls::RustlsConfig;

#[tokio::main]
async fn main() {
    // Создание маршрутизатора
    let app = Router::new()
        // Маршруты приложения
        ;
    
    // Настройка TLS
    let config = RustlsConfig::from_pem_file(
        "cert.pem",
        "key.pem",
    ).await.unwrap();
    
    // Запуск HTTPS сервера
    let addr = "0.0.0.0:443";
    println!("Listening on https://{}", addr);
    axum_server::bind_rustls(addr.parse().unwrap(), config)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

2. Аудит зависимостей

Регулярно проверяйте зависимости на уязвимости с помощью cargo audit:

bash
cargo install cargo-audit
cargo audit

3. Управление конфиденциальными данными

rust
use secrecy::{Secret, ExposeSecret};

#[derive(Clone)]
struct DatabaseConfig {
    host: String,
    port: u16,
    username: String,
    // Пароль хранится как Secret
    password: Secret<String>,
}

impl DatabaseConfig {
    fn connection_string(&self) -> String {
        format!(
            "postgres://{}:{}@{}:{}/mydb",
            self.username,
            self.password.expose_secret(), // Явное раскрытие секрета
            self.host,
            self.port
        )
    }
}

// Секреты не попадают в логи
impl std::fmt::Debug for DatabaseConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DatabaseConfig")
            .field("host", &self.host)
            .field("port", &self.port)
            .field("username", &self.username)
            .field("password", &"[REDACTED]")
            .finish()
    }
}

4. Проверка списков контроля доступа

rust
use axum::{
    extract::{Path, State},
    http::StatusCode,
};
use std::sync::Arc;

#[derive(Clone)]
struct AppState {
    db: PgPool,
}

// Проверка доступа к ресурсу
async fn check_resource_access(
    db: &PgPool,
    resource_id: i32,
    user_id: i32,
) -> Result<bool, sqlx::Error> {
    let result = sqlx::query!(
        "SELECT EXISTS(SELECT 1 FROM resource_permissions WHERE resource_id = $1 AND user_id = $2)",
        resource_id,
        user_id
    )
    .fetch_one(db)
    .await?;
    
    Ok(result.exists.unwrap_or(false))
}

// Хендлер с проверкой доступа
async fn get_resource(
    Path(resource_id): Path<i32>,
    State(state): State<Arc<AppState>>,
    claims: Claims, // Извлечены из JWT
) -> Result<impl IntoResponse, StatusCode> {
    // Проверка доступа
    let has_access = check_resource_access(
        &state.db,
        resource_id,
        claims.user_id,
    )
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    
    if !has_access {
        return Err(StatusCode::FORBIDDEN);
    }
    
    // Получение и возврат ресурса
    // ...
    
    Ok(StatusCode::OK)
}

5. Безопасность в Docker

Dockerfile
# Используем непривилегированного пользователя
FROM debian:bookworm-slim

# Создаем непривилегированного пользователя
RUN adduser --disabled-password --gecos '' appuser

# Устанавливаем права на директорию
WORKDIR /app
COPY --from=builder /app/target/release/my-app /app/my-app
RUN chown -R appuser:appuser /app

# Переключаемся на непривилегированного пользователя
USER appuser

# Запускаем приложение
CMD ["./my-app"]

6. Сканирование уязвимостей в CI/CD

yaml
# .github/workflows/security-scan.yml
name: Security Scan

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  security_scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      
      - name: Dependency audit
        run: |
          cargo install cargo-audit
          cargo audit
      
      - name: Run Trivy security scan
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'fs'
          format: 'table'
          exit-code: '1'
          severity: 'CRITICAL,HIGH'

Соблюдение принципов безопасности с самого начала разработки — ключевой фактор для создания надежных приложений на Axum. Регулярно обновляйте зависимости, следите за новыми уязвимостями и учитывайте лучшие практики, описанные в этом руководстве.