Axum Request Validation and Data Sanitization in Rust Explained

When building web applications in Rust using the Axum framework, ensuring that the data your server receives is both valid and safe is a top priority. Request validation and data sanitization are essential steps in protecting your app from invalid inputs and malicious attacks like SQL injection or cross-site scripting (XSS).

In this tutorial, we’ll explore how to handle both request validation and data sanitization in Axum. By the end, you'll understand how to properly validate user inputs, ensure data integrity, and sanitize unsafe data.

Let’s build it step-by-step!


Step 1: Setting up the Axum Project

First, let's create a new Rust project and add Axum as a dependency. If you haven’t already, you can install Cargo (Rust's package manager) and create a new project:

cargo new axum-validation
cd axum-validation

Now, in the Cargo.toml file, add Axum and Tokio as dependencies:

[dependencies]
axum = "0.6"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
validator = { version = "0.14", features = ["derive"] }
ammonia = "4.0.0"

We'll use the validator crate for request validation and ammonia crate for data sanitization.


Step 2: A Minimal "Hello, World!" Axum Server

Let’s start by building a simple "Hello, World!" server to ensure everything is set up correctly:

In your main.rs file:

use axum::{
    routing::get,
    Router,
};
use tokio::main;

#[main]
async fn main() {
    let app = Router::new().route("/", get(|| async { "Hello, World!" }));

    // Run the server
    axum::Server::bind(&"127.0.0.1:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

This simple server listens on 127.0.0.1:3000 and responds with "Hello, World!" when you access the root URL. Run the server with cargo run and visit http://localhost:3000/ to see it in action.


Step 3: Adding Request Validation

Now, let's introduce the concept of request validation. We'll be creating a POST endpoint that expects a JSON body, and we'll validate the data before processing it.

We’ll define a struct with fields that we expect in the request and use the validator crate to enforce validation rules.

First, define the struct in main.rs:

use axum::{
    extract::{Json, Path},
    http::StatusCode,
    routing::post,
    Router,
};
use serde::{Deserialize, Serialize};
use validator::Validate;

#[derive(Deserialize, Validate)]
struct User {
    #[validate(length(min = 1))]
    name: String,
    #[validate(email)]
    email: String,
}

async fn create_user(Json(payload): Json<User>) -> Result<String, StatusCode> {
    // Validate the incoming data
    if let Err(_) = payload.validate() {
        return Err(StatusCode::BAD_REQUEST);
    }

    Ok(format!("User {} created!", payload.name))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/user", post(create_user));

    axum::Server::bind(&"127.0.0.1:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

Explanation:

  • User Struct: We’ve created a struct User with name and email fields. The #[validate(...)] attributes specify validation rules:

    • length(min = 1) ensures the name field is not empty.
    • email ensures the email field is a valid email address.
  • create_user Function: The create_user handler extracts the User object from the JSON payload and validates it using payload.validate(). If the validation fails, it returns a BAD_REQUEST (400) status code. If the data is valid, it sends a success message.


Step 4: Data Sanitization

Next, we need to ensure that any potentially unsafe input is sanitized. This step helps to prevent vulnerabilities like XSS. In this example, we'll sanitize the name field to remove any harmful HTML.

Add a simple sanitization function in your main.rs file:

use ammonia::clean;

fn sanitize_input(input: &str) -> String {
    clean(input).to_string()
}

Now, in the create_user function, sanitize the name before sending a response:

async fn create_user(Json(payload): Json<User>) -> Result<String, StatusCode> {
    // Validate the incoming data
    if let Err(_) = payload.validate() {
        return Err(StatusCode::BAD_REQUEST);
    }

    let sanitized_name = sanitize_input(&payload.name);

    Ok(format!("User {} created!", sanitized_name))
}

Explanation:

  • Sanitize Function: We use the ammonia crate to sanitize the name input, ensuring that any malicious HTML or JavaScript is removed. This prevents XSS attacks.

Challenges or Questions

Now that you've seen how to validate and sanitize data, try the following:

  1. Validation Challenge: Modify the User struct to include a age field. Ensure that age is an integer and falls within a valid range (e.g., between 18 and 100).
  2. Sanitization Challenge: Use a different sanitization method or library and observe how it changes the sanitization process. Can you handle other types of malicious data, such as SQL injection strings?

Recap and Conclusion

In this tutorial, we’ve covered:

  1. Request Validation: Using the validator crate to ensure that incoming data adheres to the rules we define (like checking for a valid email or a non-empty name).
  2. Data Sanitization: Using the ammonia crate to clean user input and prevent XSS attacks by removing unsafe HTML content.
  3. Axum Setup: We set up a simple Axum server and built out a POST endpoint that handles user data securely.

By applying both validation and sanitization in your Axum application, you’ve taken important steps toward building a robust and secure backend.

To dive deeper, you could explore more advanced validation techniques, or experiment with different Axum features like middleware and error handling.

Read more