Rust programs can be surprisingly vulnerable to common web exploits if you don’t pay attention to how you’re handling input and state.

Let’s see what a typical Rust web service looks like under the hood, focusing on what actually matters for production.

Imagine we have a simple actix-web service that takes a user ID from a URL and fetches data from a database.

use actix_web::{get, web, App, HttpResponse, HttpServer, Responder};
use sqlx::{Pool, Postgres};
use serde::Deserialize;

#[derive(Deserialize)]
struct UserParams {
    id: i32,
}

#[get("/users/{id}")]
async fn get_user(
    pool: web::Data<Pool<Postgres>>,
    params: web::Path<UserParams>,
) -> impl Responder {
    let user_id = params.id;
    // In a real app, this would be a database query
    // let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", user_id).fetch_one(pool.get_ref()).await;

    // For demonstration:
    if user_id < 1 || user_id > 1000 { // Basic validation
        return HttpResponse::BadRequest().body("Invalid user ID");
    }

    HttpResponse::Ok().body(format!("Fetching user with ID: {}", user_id))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // In a real app, you'd configure a database connection pool here
    // let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    // let pool = Pool::<Postgres>::connect(&database_url).await.expect("Failed to create pool");

    // For demonstration:
    let pool = web::Data::new(()); // Dummy pool

    HttpServer::new(move || {
        App::new()
            .app_data(pool.clone())
            .service(get_user)
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

This code looks pretty straightforward, right? We’re getting an id from the path, doing a quick check, and returning a string. But production readiness involves more than just happy path correctness.

Security

The biggest shocker for many is how easily a Rust app can be a conduit for attacks if input isn’t treated with suspicion.

  • Injection Vulnerabilities (SQL, Command, etc.): This is the classic. If you’re interpolating user-provided strings directly into SQL queries, database commands, or shell commands, you’re asking for trouble.

    • Diagnosis: Static analysis tools like cargo-audit and cargo-semver-checks can flag outdated dependencies that might have known vulnerabilities. For actual code, manual review of all external input paths and any string formatting that forms commands is key. Look for format!, println!, or string concatenation used to build queries or commands.
    • Fix: Always use parameterized queries or prepared statements for SQL. For shell commands, use libraries like std::process::Command and pass arguments as arguments, not as part of a single string.
      // BAD:
      // let query = format!("SELECT * FROM users WHERE id = {}", user_id);
      // sqlx::query(&query).fetch_one(pool).await;
      
      // GOOD:
      let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", user_id)
          .fetch_one(pool.get_ref())
          .await;
      
      This works because the database driver (or OS for shell commands) separates the command structure from the data, preventing the data from being interpreted as executable code.
    • Why it works: The database engine or shell interpreter receives the command and the data separately. It knows which parts are code to execute and which parts are just values to compare against, effectively neutralizing any "malicious" code embedded in the data.
  • Cross-Site Scripting (XSS): If your Rust backend serves HTML or data that’s rendered directly in a browser without proper escaping, an attacker could inject malicious JavaScript.

    • Diagnosis: This is primarily a frontend concern, but your backend enables it. Check if any dynamic data rendered into HTML responses is passed through an escaping mechanism. cargo-audit can sometimes flag libraries with known XSS issues, but code review is critical.
    • Fix: Use a templating engine like askama, tera, or maud which often have automatic HTML escaping enabled by default. If you’re manually building HTML strings, ensure all dynamic content is properly escaped using functions like html_escape::encode_text.
      // Using askama:
      // #[derive(Template)]
      // #[template(path = "user.html")]
      // struct UserTemplate<'a> {
      //     user_name: &'a str,
      // }
      // // The template engine handles escaping `user_name` when it's rendered.
      
      // Manual escaping:
      use html_escape::encode_text;
      let safe_user_name = encode_text(user_name);
      let html_response = format!("<p>Welcome, {}!</p>", safe_user_name);
      
      Automatic escaping ensures characters like <, >, and & in user-provided text are converted to their HTML entities (&lt;, &gt;, &amp;), so the browser interprets them as literal characters rather than HTML tags or script delimiters.
  • Denial of Service (DoS) via Resource Exhaustion: A seemingly innocent API endpoint, if it performs expensive computations or consumes large amounts of memory based on user input, can be exploited.

    • Diagnosis: Profile your application under load. Identify endpoints that consume disproportionate CPU or memory. Look for unbounded loops, recursive functions without a base case, or operations on arbitrarily large data structures derived from user input.
    • Fix: Implement rate limiting on your API endpoints using middleware (e.g., actix_ratelimit). For computationally intensive tasks, consider offloading them to background workers or limiting the size of input data (e.g., maximum file upload size, maximum JSON payload size).
      // Example of limiting JSON payload size in Actix-web
      use actix_web::middleware::NormalizePath;
      use actix_web::web::JsonConfig;
      
      HttpServer::new(move || {
          App::new()
              .app_data(web::JsonConfig::default().limit(4096)) // Limit JSON payload to 4KB
              .service(get_user)
      })
      // ...
      
      This prevents a single request from consuming excessive resources by enforcing a hard limit on the amount of data the server will process for JSON payloads, thereby preventing attackers from sending gigabytes of data to crash the service.
  • Insecure Dependencies: Relying on outdated or malicious crates is a significant risk.

    • Diagnosis: Regularly run cargo audit. Pay attention to advisories and consider updating dependencies promptly.
    • Fix: Update dependencies to the latest secure versions. If a vulnerability cannot be immediately fixed by updating, consider vendoring the crate or disabling the affected feature if possible.
      cargo update --aggressive
      cargo audit check
      
      Updating dependencies often pulls in versions that have had known security flaws patched by the maintainers, directly removing the vulnerability from your codebase.

Performance

Rust’s performance is a selling point, but it’s not automatic.

  • Excessive Allocations: Frequent heap allocations, especially within hot loops, can cripple performance.

    • Diagnosis: Use profiling tools like perf or flamegraph to identify functions with high allocation counts. The heaptrack tool is also excellent for detailed memory profiling.
    • Fix: Use arenas, stack allocation where possible, or reuse buffers. For collections, pre-allocate capacity if the size is known.
      // BAD:
      // let mut v = Vec::new();
      // for i in 0..1000 {
      //     v.push(i); // Reallocates many times
      // }
      
      // GOOD:
      let mut v = Vec::with_capacity(1000);
      for i in 0..1000 {
          v.push(i); // Allocates once
      }
      
      Vec::with_capacity pre-allocates enough memory upfront to hold 1000 elements, avoiding multiple costly reallocations and copies as the vector grows.
  • Blocking I/O in Async Contexts: Performing long-running, blocking operations (like synchronous file I/O or heavy CPU-bound tasks) within an async function without proper handling will stall the entire executor thread.

    • Diagnosis: Profiling will often show these blocking calls as long, contiguous blocks of time on the async runtime’s threads. Tools like tokio-console can help visualize task execution.
    • Fix: Use asynchronous equivalents for I/O operations (e.g., tokio::fs instead of std::fs). For CPU-bound tasks, use tokio::task::spawn_blocking to move the blocking work to a dedicated thread pool.
      use tokio::task;
      use std::fs;
      
      async fn read_file_async(path: String) -> std::io::Result<String> {
          // BAD: Blocking read in async context
          // let content = fs::read_to_string(path)?;
      
          // GOOD: Use spawn_blocking for CPU-bound or blocking I/O
          let content = task::spawn_blocking(move || fs::read_to_string(path))
              .await
              .expect("Task panicked")?; // Handle potential panic
      
          Ok(content)
      }
      
      spawn_blocking ensures that the synchronous file read doesn’t block the async event loop, allowing other asynchronous tasks to continue making progress while the file is being read on a separate thread.

Monitoring

Visibility into your running application is crucial.

  • Lack of Metrics: You can’t fix what you can’t see. Without metrics, diagnosing performance or errors in production is guesswork.

    • Diagnosis: Check if you have any instrumentation. Are you emitting metrics about request latency, error rates, resource usage (CPU, memory), or custom application-specific counters?
    • Fix: Integrate a metrics library like prometheus or metrics. Expose an HTTP endpoint (e.g., /metrics) that Prometheus can scrape. Instrument key parts of your application: request handlers, database calls, background job processing.
      use actix_web::{get, App, HttpResponse, HttpServer, Responder};
      use actix_web_prom::PrometheusMetricsBuilder;
      use std::io;
      
      #[get("/hello")]
      async fn hello() -> impl Responder {
          HttpResponse::Ok().body("Hello!")
      }
      
      #[actix_web::main]
      async fn main() -> io::Result<()> {
          let prometheus = PrometheusMetricsBuilder::new("/metrics")
              .endpoint("/metrics")
              .build()
              .unwrap();
      
          HttpServer::new(move || {
              App::new()
                  .wrap(prometheus.clone()) // Register middleware
                  .service(hello)
          })
          .bind("127.0.0.1:8080")?
          .run()
          .await
      }
      
      The actix_web_prom middleware automatically collects metrics like request duration, count, and status codes, exposing them at the /metrics endpoint for collection by Prometheus.
  • Insufficient Logging: Logs are your primary tool for understanding what happened before an error occurred or what specific request failed.

    • Diagnosis: Review your logging configuration. Are you logging errors with sufficient context? Are you logging at an appropriate level (e.g., INFO for normal operations, ERROR for failures)? Is log output structured for easy parsing?
    • Fix: Use a structured logging library like tracing or log. Configure your logger to output in a machine-readable format (e.g., JSON). Ensure you’re logging relevant context: request IDs, user IDs (if applicable and safe), error messages, stack traces.
      use tracing::{error, info, instrument};
      use tracing_subscriber;
      
      #[instrument] // Adds span context automatically
      async fn process_request(user_id: i32) -> Result<(), String> {
          info!("Processing request for user {}", user_id);
          if user_id == 0 {
              error!("Invalid user ID received: {}", user_id);
              return Err("Invalid user ID".to_string());
          }
          // ... actual processing ...
          Ok(())
      }
      
      #[actix_web::main]
      async fn main() -> std::io::Result<()> {
          tracing_subscriber::fmt::init(); // Initialize tracing
      
          // ... your http server setup ...
          // In a handler:
          // let result = process_request(user_id_from_request).await;
          // if result.is_err() {
          //    // Log the error again if necessary, or rely on the instrumented function
          // }
          Ok(())
      }
      
      The #[instrument] macro automatically creates a span for the function, associating all logs within that function with its context. tracing_subscriber then formats these logs, making them easily searchable and filterable.
  • No Tracing: While logs tell you what happened and metrics tell you how much, distributed tracing tells you where a request went across multiple services.

    • Diagnosis: If your application is part of a larger microservices architecture, do you have trace IDs propagating between services? Can you visualize the end-to-end flow of a request?
    • Fix: Integrate a tracing library like opentelemetry with a backend like Jaeger or Zipkin. Ensure trace context (like trace IDs and span IDs) is propagated correctly, typically via HTTP headers. Libraries like opentelemetry-actix can help with this.
      // Example conceptually, actual setup is more involved with OpenTelemetry SDKs
      // In your HTTP client:
      // let client = reqwest::Client::new();
      // let request_builder = client.get("http://other-service/data")
      //     .header("traceparent", "00-0af76543210000000000000000000000-1000000000000000-01"); // Example header
      
      // In your server middleware:
      // On incoming requests, extract "traceparent" header.
      // Create a new span.
      // When making outgoing requests, inject the current span's context into headers.
      
      Trace context propagation ensures that a single logical request, spanning multiple services, is represented as a single trace in your tracing system, allowing you to pinpoint bottlenecks or failures across service boundaries.

The next hurdle you’ll face is managing application state and concurrency safely across multiple requests.

Want structured learning?

Take the full Rust course →