Rust Security Best Practices
Comprehensive guide for writing secure Rust code.
Dependency Security
Audit Dependencies
# Install and run cargo-audit
cargo install cargo-audit
cargo audit
# Check for unmaintained crates
cargo audit --deny unmaintained
# Generate lockfile advisories
cargo audit --json > audit-report.json
# Deny specific advisories in CI
cargo audit --deny RUSTSEC-2023-0001
Cargo.toml Security
[package]
# Pin exact versions for security-critical deps
rust-version = "1.85"
[dependencies]
# Use caret for flexibility, but audit regularly
ring = "0.17"
rustls = "0.23"
# Avoid yanked versions
# cargo update will warn about these
[features]
# Disable default features, enable only what you need
default = []
cargo-deny Configuration
# deny.toml
[advisories]
db-path = "~/.cargo/advisory-db"
vulnerability = "deny"
unmaintained = "warn"
yanked = "deny"
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause"]
copyleft = "deny"
[bans]
multiple-versions = "warn"
deny = [
# Known problematic crates
{ name = "openssl" }, # Prefer rustls
]
[sources]
unknown-registry = "deny"
unknown-git = "deny"
Input Validation
String Validation
use std::borrow::Cow;
/// Validates and sanitizes user input.
pub fn validate_username(input: &str) -> Result<Cow<'_, str>, ValidationError> {
// Length limits
if input.is_empty() {
return Err(ValidationError::Empty);
}
if input.len() > 64 {
return Err(ValidationError::TooLong { max: 64 });
}
// Character whitelist
if !input.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
return Err(ValidationError::InvalidCharacters);
}
// Reserved names
const RESERVED: &[&str] = &["admin", "root", "system", "null"];
if RESERVED.contains(&input.to_lowercase().as_str()) {
return Err(ValidationError::Reserved);
}
Ok(Cow::Borrowed(input))
}
Path Traversal Prevention
use std::path::{Path, PathBuf};
/// Safely joins a user-provided path to a base directory.
pub fn safe_join(base: &Path, user_path: &str) -> Result<PathBuf, SecurityError> {
// Reject absolute paths
let user_path = Path::new(user_path);
if user_path.is_absolute() {
return Err(SecurityError::AbsolutePathRejected);
}
// Reject path traversal
for component in user_path.components() {
match component {
std::path::Component::ParentDir => {
return Err(SecurityError::PathTraversal);
}
std::path::Component::Normal(_) => {}
_ => return Err(SecurityError::InvalidPathComponent),
}
}
let full_path = base.join(user_path);
// Verify the result is still under base
let canonical = full_path.canonicalize()
.map_err(|_| SecurityError::PathResolutionFailed)?;
let base_canonical = base.canonicalize()
.map_err(|_| SecurityError::PathResolutionFailed)?;
if !canonical.starts_with(&base_canonical) {
return Err(SecurityError::PathEscape);
}
Ok(canonical)
}
SQL Injection Prevention
use sqlx::{query, query_as, PgPool};
// BAD - string interpolation
async fn bad_query(pool: &PgPool, user_id: &str) {
let query = format!("SELECT * FROM users WHERE id = '{}'", user_id);
// VULNERABLE TO SQL INJECTION
}
// GOOD - parameterized queries
async fn good_query(pool: &PgPool, user_id: &str) -> Result<User, Error> {
query_as!(User, "SELECT * FROM users WHERE id = $1", user_id)
.fetch_one(pool)
.await
.map_err(Error::from)
}
// GOOD - using sqlx macros (compile-time checked)
async fn safe_query(pool: &PgPool, user_id: Uuid) -> Result<User, Error> {
sqlx::query_as!(
User,
r#"SELECT id, name, email FROM users WHERE id = $1"#,
user_id
)
.fetch_one(pool)
.await
.map_err(Error::from)
}
Command Injection Prevention
use std::process::Command;
// BAD - shell interpolation
fn bad_command(filename: &str) {
Command::new("sh")
.arg("-c")
.arg(format!("cat {}", filename)) // VULNERABLE
.output();
}
// GOOD - direct argument passing
fn good_command(filename: &str) -> std::io::Result<Vec<u8>> {
let output = Command::new("cat")
.arg(filename) // Passed as single argument, no shell interpretation
.output()?;
Ok(output.stdout)
}
// BETTER - avoid shell entirely when possible
fn read_file(filename: &str) -> std::io::Result<Vec<u8>> {
std::fs::read(filename)
}
Authentication & Authorization
Password Hashing
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
/// Hashes a password using Argon2id.
pub fn hash_password(password: &str) -> Result<String, AuthError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|_| AuthError::HashingFailed)
}
/// Verifies a password against a hash.
pub fn verify_password(password: &str, hash: &str) -> Result<bool, AuthError> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|_| AuthError::InvalidHash)?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
Token Generation
use rand::{rngs::OsRng, RngCore};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
/// Generates a cryptographically secure random token.
pub fn generate_token(length: usize) -> String {
let mut bytes = vec![0u8; length];
OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(&bytes)
}
/// Generates a session ID.
pub fn generate_session_id() -> String {
generate_token(32) // 256 bits of entropy
}
/// Generates a CSRF token.
pub fn generate_csrf_token() -> String {
generate_token(32)
}
Constant-Time Comparison
use subtle::ConstantTimeEq;
/// Compares two tokens in constant time to prevent timing attacks.
pub fn secure_compare(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.ct_eq(b).into()
}
// For string tokens
pub fn secure_compare_str(a: &str, b: &str) -> bool {
secure_compare(a.as_bytes(), b.as_bytes())
}
Secrets Management
Secure String Handling
use secrecy::{ExposeSecret, Secret};
use zeroize::Zeroize;
/// A password that is zeroized on drop.
pub struct Password(Secret<String>);
impl Password {
pub fn new(password: String) -> Self {
Self(Secret::new(password))
}
pub fn expose(&self) -> &str {
self.0.expose_secret()
}
}
// For custom types
#[derive(Zeroize)]
#[zeroize(drop)]
pub struct ApiKey {
key: String,
}
impl Drop for ApiKey {
fn drop(&mut self) {
self.zeroize();
}
}
Environment Variables
use secrecy::Secret;
use std::env;
/// Loads secrets from environment variables.
pub struct Secrets {
pub database_url: Secret<String>,
pub api_key: Secret<String>,
pub jwt_secret: Secret<String>,
}
impl Secrets {
pub fn from_env() -> Result<Self, ConfigError> {
Ok(Self {
database_url: Secret::new(
env::var("DATABASE_URL")
.map_err(|_| ConfigError::MissingEnv("DATABASE_URL"))?
),
api_key: Secret::new(
env::var("API_KEY")
.map_err(|_| ConfigError::MissingEnv("API_KEY"))?
),
jwt_secret: Secret::new(
env::var("JWT_SECRET")
.map_err(|_| ConfigError::MissingEnv("JWT_SECRET"))?
),
})
}
}
// NEVER log secrets
impl std::fmt::Debug for Secrets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Secrets")
.field("database_url", &"[REDACTED]")
.field("api_key", &"[REDACTED]")
.field("jwt_secret", &"[REDACTED]")
.finish()
}
}
Cryptography
Use High-Level Libraries
// GOOD - use established, audited libraries
use ring::rand::SecureRandom;
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
pub struct Encryptor {
key: LessSafeKey,
}
impl Encryptor {
pub fn new(key_bytes: &[u8; 32]) -> Result<Self, CryptoError> {
let unbound_key = UnboundKey::new(&AES_256_GCM, key_bytes)
.map_err(|_| CryptoError::InvalidKey)?;
Ok(Self {
key: LessSafeKey::new(unbound_key),
})
}
pub fn encrypt(&self, plaintext: &[u8], nonce: &[u8; 12]) -> Result<Vec<u8>, CryptoError> {
let nonce = Nonce::assume_unique_for_key(*nonce);
let mut in_out = plaintext.to_vec();
self.key
.seal_in_place_append_tag(nonce, Aad::empty(), &mut in_out)
.map_err(|_| CryptoError::EncryptionFailed)?;
Ok(in_out)
}
}
TLS Configuration
use rustls::{ClientConfig, RootCertStore};
use std::sync::Arc;
/// Creates a secure TLS client configuration.
pub fn create_tls_config() -> Result<Arc<ClientConfig>, TlsError> {
let mut root_store = RootCertStore::empty();
// Use webpki-roots for trusted CAs
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
Ok(Arc::new(config))
}
// For reqwest
use reqwest::Client;
pub fn create_secure_client() -> Result<Client, reqwest::Error> {
Client::builder()
.use_rustls_tls()
.min_tls_version(reqwest::tls::Version::TLS_1_2)
.https_only(true)
.build()
}
Rate Limiting & DoS Prevention
Rate Limiter
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub struct RateLimiter {
requests: Mutex<HashMap<String, Vec<Instant>>>,
max_requests: usize,
window: Duration,
}
impl RateLimiter {
pub fn new(max_requests: usize, window: Duration) -> Self {
Self {
requests: Mutex::new(HashMap::new()),
max_requests,
window,
}
}
pub fn check(&self, key: &str) -> Result<(), RateLimitError> {
let mut requests = self.requests.lock().unwrap();
let now = Instant::now();
let cutoff = now - self.window;
let timestamps = requests.entry(key.to_string()).or_default();
// Remove old timestamps
timestamps.retain(|&t| t > cutoff);
if timestamps.len() >= self.max_requests {
return Err(RateLimitError::TooManyRequests);
}
timestamps.push(now);
Ok(())
}
}
Request Size Limits
use axum::{
body::Body,
extract::Request,
middleware::Next,
response::Response,
};
use http::StatusCode;
const MAX_BODY_SIZE: u64 = 1024 * 1024; // 1 MB
pub async fn limit_body_size(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let content_length = request
.headers()
.get(http::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
if let Some(length) = content_length {
if length > MAX_BODY_SIZE {
return Err(StatusCode::PAYLOAD_TOO_LARGE);
}
}
Ok(next.run(request).await)
}
Logging Security
Safe Logging
use tracing::{info, warn, error, instrument};
// BAD - logs sensitive data
fn bad_login(username: &str, password: &str) {
info!("Login attempt: user={}, pass={}", username, password);
}
// GOOD - redact sensitive fields
#[instrument(skip(password), fields(username = %username))]
fn good_login(username: &str, password: &str) -> Result<(), AuthError> {
info!("Login attempt");
// ... authentication logic
Ok(())
}
// Use skip for sensitive parameters
#[instrument(skip(api_key, request_body))]
async fn api_call(
endpoint: &str,
api_key: &str,
request_body: &[u8],
) -> Result<Response, Error> {
info!("API call to {}", endpoint);
// ...
}
// Redact in error messages
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Invalid credentials")] // Don't say which one
InvalidCredentials,
#[error("Account locked")]
AccountLocked,
}
Security Headers
use axum::{
middleware::Next,
response::Response,
http::{Request, header},
};
pub async fn security_headers<B>(
request: Request<B>,
next: Next<B>,
) -> Response {
let mut response = next.run(request).await;
let headers = response.headers_mut();
// Prevent XSS
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
"nosniff".parse().unwrap(),
);
// Prevent clickjacking
headers.insert(
header::X_FRAME_OPTIONS,
"DENY".parse().unwrap(),
);
// HSTS
headers.insert(
header::STRICT_TRANSPORT_SECURITY,
"max-age=31536000; includeSubDomains".parse().unwrap(),
);
// CSP
headers.insert(
header::CONTENT_SECURITY_POLICY,
"default-src 'self'".parse().unwrap(),
);
response
}
Security Checklist
Before Release
Crate Recommendations
| Purpose |
Recommended Crate |
| Password hashing |
argon2 |
| Cryptography |
ring, rustls |
| TLS |
rustls (not openssl) |
| Secrets |
secrecy, zeroize |
| Random |
rand with OsRng |
| Timing-safe compare |
subtle |
| HTTP client |
reqwest with rustls |
| SQL |
sqlx with compile-time checks |