Безопасность в Axum-приложениях
Безопасность является критически важным аспектом любого веб-приложения. В этом руководстве рассмотрены основные практики защиты приложений на Axum от распространенных уязвимостей.
Содержание
- Аутентификация и авторизация
- Защита от CSRF
- Предотвращение XSS
- SQL-инъекции
- Безопасные заголовки
- Ограничение скорости запросов
- Валидация входных данных
- Обработка ошибок
- CORS
- Защита от DoS
- Лучшие практики
Аутентификация и авторизация
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. Регулярно обновляйте зависимости, следите за новыми уязвимостями и учитывайте лучшие практики, описанные в этом руководстве.