Skip to content

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

src/validation.rs
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()
                ));
            }
        }

        // ...
    }
}
Warning
Always validate 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('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace(''', "&#x27;")
}

// 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 HTML

Rate 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:

src/middleware/rate_limit.rs
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);
Note
This is a per-process rate limiter. In production with multiple server instances, you'd use a shared store like Redis. Also note that 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-pemfile
src/tls.rs
use 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/
Tip
In production, use certificates from Let's Encrypt. Most deployments run behind a reverse proxy (nginx, Caddy) that handles TLS termination. If you're serving TLS directly, use the 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:

src/middleware/security_headers.rs
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)
    }
}
HeaderPrevents
X-Content-Type-Options: nosniffBrowser guessing file types - prevents serving a JS file as HTML
X-Frame-Options: DENYClickjacking - your page embedded in a malicious iframe
Strict-Transport-SecurityHTTP downgrade attacks - forces HTTPS after first visit
Content-Security-PolicyXSS, data injection - restricts where scripts/styles can load from
Referrer-PolicyLeaking URLs to third parties via the Referer header
Note
Test your headers at 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.

Tip
Rust eliminates the memory-safety vulnerabilities that dominate CVE databases (Microsoft reports ~70% of their security bugs are memory safety issues). But web application security is a different layer - you still need input validation, output encoding, access control, and secure configuration. Rust gives you a rock-solid foundation; you build the application security on top.

Server Security Checklist

A summary of everything from this chapter and previous ones, organized as a pre-deployment checklist:

01Input validation on all user-supplied data (body, headers, query params)
02Request size limits (body, headers, header count)
03Parameterized database queries - never string interpolation
04HTML output escaping for any user-generated content
05Rate limiting per IP or per token
06TLS for all traffic (or behind a TLS-terminating proxy)
07Security headers on every response (CSP, HSTS, X-Frame-Options)
08Directory traversal prevention (canonicalize + starts_with)
09No secrets in source code, logs, or error messages
10Timeout on request reads (prevent slowloris attacks)
11Connection limits (prevent resource exhaustion)
12Error responses don't leak internal details to clients

Exercise

  1. Implement the SecurityHeaders middleware from this chapter. Test it by checking curl -I output for all the expected headers.
  2. 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
  3. 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).
  4. 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::timeout around the request read and verify the slow connection gets dropped.
  5. 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.