Chapter 23
Security & Hardening
A working server isn't a safe server. We need to validate input, limit abuse, encrypt traffic, add security headers, and understand what Rust protects against - and what it doesn't.
Input Validation and Sanitization
Every piece of data from a client is untrusted. Request paths, headers, query parameters, JSON bodies - all of it must be validated before use. We've already handled some of this (path traversal in Chapter 15, JSON parsing in Chapter 14), but let's be systematic.
Validating Request Bodies
use crate::error::ServerError;
/// Validation errors with field-level detail.
#[derive(Debug, serde::Serialize)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
pub fn validate_create_todo(title: &str) -> Result<(), Vec<ValidationError>> {
let mut errors = vec![];
if title.trim().is_empty() {
errors.push(ValidationError {
field: "title".into(),
message: "title must not be empty".into(),
});
}
if title.len() > 500 {
errors.push(ValidationError {
field: "title".into(),
message: "title must be 500 characters or fewer".into(),
});
}
// Check for control characters (null bytes, etc.)
if title.chars().any(|c| c.is_control() && c != '\n') {
errors.push(ValidationError {
field: "title".into(),
message: "title contains invalid characters".into(),
});
}
if errors.is_empty() { Ok(()) } else { Err(errors) }
}Use it in the handler:
fn create_todo(req: &Request) -> Result<Response, ServerError> {
let input: CreateTodo = req.json_body()?;
if let Err(errors) = validate_create_todo(&input.title) {
return Ok(Response::json(400, "Bad Request", &serde_json::json!({
"errors": errors
})));
}
let todo = db.create_todo(input).await?;
Ok(Response::json_created(&todo))
}Request Size Limits
Without limits, an attacker can send a multi-gigabyte request body and exhaust server memory:
const MAX_REQUEST_SIZE: usize = 1024 * 1024; // 1 MB
const MAX_HEADER_SIZE: usize = 8192; // 8 KB
const MAX_HEADERS: usize = 100;
fn validate_request_size(raw: &[u8]) -> Result<(), ServerError> {
if raw.len() > MAX_REQUEST_SIZE {
return Err(ServerError::ParseError(format!(
"request body too large: {} bytes (max {})",
raw.len(), MAX_REQUEST_SIZE
)));
}
Ok(())
}
impl Request {
pub fn parse(raw: &str) -> Result<Request, ServerError> {
// ... existing parsing ...
// Limit number of headers
if headers.len() > MAX_HEADERS {
return Err(ServerError::ParseError(
"too many headers".into()
));
}
// Validate Content-Length before reading body
if let Some(len) = headers.get("content-length") {
let size: usize = len.parse().map_err(|_| {
ServerError::ParseError("invalid Content-Length".into())
})?;
if size > MAX_REQUEST_SIZE {
return Err(ServerError::ParseError(
"Content-Length exceeds limit".into()
));
}
}
// ...
}
}Content-Length before reading the body. If you read first and validate after, the damage is already done - the bytes are in memory. Check the header, reject if too large, then read.Sanitizing Output
If you embed user input in HTML responses, escape it to prevent XSS (cross-site scripting):
/// Escape HTML special characters to prevent XSS.
fn escape_html(input: &str) -> String {
input
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace(''', "'")
}
// WRONG - user input rendered as HTML
let body = format!("<h1>Search: {}</h1>", query);
// If query is "<script>alert('xss')</script>", it executes!
// RIGHT - escaped
let body = format!("<h1>Search: {}</h1>", escape_html(query));
// Renders as text, not executable HTMLRate Limiting
Without rate limiting, an attacker can flood your server with requests, exhausting resources or brute-forcing credentials. A simple token-bucket rate limiter tracks requests per IP:
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::error::ServerError;
use crate::middleware::{Middleware, Service};
use crate::request::Request;
use crate::response::Response;
struct Bucket {
tokens: u32,
last_refill: Instant,
}
pub struct RateLimiter {
max_requests: u32,
window: Duration,
buckets: Arc<Mutex<HashMap<String, Bucket>>>,
}
impl RateLimiter {
pub fn new(max_requests: u32, window: Duration) -> Self {
RateLimiter {
max_requests,
window,
buckets: Arc::new(Mutex::new(HashMap::new())),
}
}
fn check(&self, ip: &str) -> bool {
let mut buckets = self.buckets.lock().unwrap();
let now = Instant::now();
let bucket = buckets.entry(ip.to_string()).or_insert(Bucket {
tokens: self.max_requests,
last_refill: now,
});
// Refill tokens if the window has elapsed
if now.duration_since(bucket.last_refill) >= self.window {
bucket.tokens = self.max_requests;
bucket.last_refill = now;
}
if bucket.tokens > 0 {
bucket.tokens -= 1;
true
} else {
false
}
}
}
impl Middleware for RateLimiter {
fn wrap(&self, inner: Arc<dyn Service>) -> Arc<dyn Service> {
let limiter = Arc::new(RateLimiter {
max_requests: self.max_requests,
window: self.window,
buckets: Arc::clone(&self.buckets),
});
Arc::new(move |req: &Request| -> Result<Response, ServerError> {
let ip = req.headers
.get("x-forwarded-for")
.or(req.headers.get("x-real-ip"))
.cloned()
.unwrap_or_else(|| "unknown".to_string());
if !limiter.check(&ip) {
let mut resp = Response::new(
429,
"Too Many Requests",
"Rate limit exceeded. Try again later.",
);
resp.add_header("Retry-After", "60");
return Ok(resp);
}
inner.call(req)
})
}
}Usage:
use std::time::Duration;
let mut stack = Stack::new();
stack.push(RateLimiter::new(
100, // max 100 requests
Duration::from_secs(60), // per 60-second window
));
stack.push(LoggingMiddleware);X-Forwarded-For can be spoofed - only trust it if your reverse proxy sets it.TLS with rustls - Serving HTTPS
HTTP sends everything in plaintext - passwords, tokens, personal data - readable by anyone on the network. TLS encrypts the connection. rustls is a pure-Rust TLS implementation - no OpenSSL dependency, no C code, memory-safe:
cargo add tokio-rustls
cargo add rustls-pemfileuse std::fs::File;
use std::io::BufReader;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use rustls::ServerConfig;
pub async fn create_tls_acceptor(
cert_path: &str,
key_path: &str,
) -> Result<TlsAcceptor, Box<dyn std::error::Error>> {
// Load certificate chain
let cert_file = File::open(cert_path)?;
let certs = rustls_pemfile::certs(&mut BufReader::new(cert_file))
.collect::<Result<Vec<_>, _>>()?;
// Load private key
let key_file = File::open(key_path)?;
let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))?
.ok_or("no private key found")?;
// Build TLS config
let config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
Ok(TlsAcceptor::from(Arc::new(config)))
}Integrate it into the server loop:
#[tokio::main]
async fn main() {
let tls = create_tls_acceptor("cert.pem", "key.pem")
.await
.expect("failed to load TLS certificates");
let listener = TcpListener::bind("0.0.0.0:443").await.unwrap();
tracing::info!("Listening on https://0.0.0.0:443");
loop {
let (tcp_stream, _) = listener.accept().await.unwrap();
// Wrap the TCP stream with TLS
let tls_stream = match tls.accept(tcp_stream).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "TLS handshake failed");
continue;
}
};
// tls_stream implements AsyncRead + AsyncWrite
// - use it exactly like a regular TcpStream
tokio::spawn(async move {
handle_connection(tls_stream).await;
});
}
}For development, generate a self-signed certificate:
# Generate a self-signed cert for localhost
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
-days 365 -nodes -subj "/CN=localhost"
# Test with curl (skip cert verification for self-signed)
curl -k https://localhost:443/rustls defaults - they disable insecure protocols and cipher suites automatically.Secure Headers
Security headers instruct the browser to enable or restrict certain behaviors. They're cheap to add and prevent entire classes of attacks. Add them as a middleware (Chapter 21) so every response gets them:
impl Service for SecurityHeadersService {
fn call(&self, req: &Request) -> Result<Response, ServerError> {
let mut resp = self.inner.call(req)?;
// Prevent MIME-type sniffing
resp.add_header("X-Content-Type-Options", "nosniff");
// Block loading in iframes (clickjacking protection)
resp.add_header("X-Frame-Options", "DENY");
// Enable browser XSS filter
resp.add_header("X-XSS-Protection", "1; mode=block");
// Force HTTPS for future requests (1 year)
resp.add_header(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains"
);
// Restrict what the page can load
resp.add_header(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
);
// Don't send Referer for cross-origin requests
resp.add_header("Referrer-Policy", "strict-origin-when-cross-origin");
// Opt out of FLoC / Topics API tracking
resp.add_header("Permissions-Policy", "interest-cohort=()");
Ok(resp)
}
}| Header | Prevents |
|---|---|
| X-Content-Type-Options: nosniff | Browser guessing file types - prevents serving a JS file as HTML |
| X-Frame-Options: DENY | Clickjacking - your page embedded in a malicious iframe |
| Strict-Transport-Security | HTTP downgrade attacks - forces HTTPS after first visit |
| Content-Security-Policy | XSS, data injection - restricts where scripts/styles can load from |
| Referrer-Policy | Leaking URLs to third parties via the Referer header |
securityheaders.com - it grades your site and suggests missing headers. You can also check with curl -I https://your-server/.Common Vulnerabilities and How Rust Helps
Rust's type system and ownership model eliminate entire classes of vulnerabilities that plague C, C++, and even garbage-collected languages. But it's not a silver bullet. Let's be precise about what Rust prevents and what it doesn't:
Rust prevents:
Buffer overflows
Array bounds are checked at runtime. You can't read or write past the end of a buffer. This eliminates the most exploited vulnerability class in history.
Use-after-free
The borrow checker ensures references can't outlive the data they point to. Dangling pointers are compile errors.
Data races
Send and Sync traits prevent sharing mutable data across threads without synchronization. If it compiles, it's race-free.
Null pointer dereferences
No null values. Option<T> forces you to handle the absence case at compile time.
Double frees / memory leaks
Ownership ensures every allocation has exactly one owner. Dropping the owner frees the memory. No manual free, no GC.
Rust does NOT prevent:
SQL injection
Rust can't stop you from writing format!("SELECT ... WHERE id = {}", user_input). Always use parameterized queries (.bind()).
Cross-site scripting (XSS)
If you embed user input in HTML without escaping, it's XSS. Rust's type system doesn't distinguish "safe HTML" from "unsafe HTML" by default.
Business logic errors
Serving admin pages to non-admin users, allowing negative quantities, miscalculating prices. These are logic bugs, not memory bugs.
Denial of service
Rust can't stop algorithmic complexity attacks (e.g., crafted input causing O(n²) parsing), resource exhaustion, or overwhelming the server with requests.
Secrets in source code / logs
Hardcoded passwords, API keys in logs, tokens in error messages. These are human errors that no language prevents.
Server Security Checklist
A summary of everything from this chapter and previous ones, organized as a pre-deployment checklist:
Exercise
- Implement the
SecurityHeadersmiddleware from this chapter. Test it by checkingcurl -Ioutput for all the expected headers. - Add request body size limits to your server. Test by sending a body larger than the limit:
dd if=/dev/zero bs=2M count=1 | curl -X POST -d @- localhost:8080/api/todos - Set up TLS with a self-signed certificate. Verify with
curl -k https://localhost:443/and check that plain HTTP on port 8080 no longer works (or redirects to HTTPS). - Write a slowloris test: open a connection and send one byte per second. Without a read timeout, the connection holds a slot forever. Add a
tokio::time::timeoutaround the request read and verify the slow connection gets dropped. - Audit your error responses: do any of them leak internal details like file paths, stack traces, or SQL errors? Replace them with generic messages and log the details server-side.
What's Next
The server is hardened: validated input, rate limiting, TLS encryption, security headers, and a clear understanding of what Rust protects against. The checklist gives you a pre-deployment audit plan.
In the next chapter, we focus on performance and profiling - benchmarking with Criterion, profiling with flamegraphs, and optimization techniques for a fast server.