Rust Web Framework Security Testing: Actix-Web, Axum, and Rocket Vulnerabilities

Rust's memory safety guarantees eliminate a whole class of vulnerabilities — buffer overflows, use-after-free, null pointer dereferences — that dominate CVE databases for C and C++ applications. But memory safety is not application security. Rust services are just as vulnerable to injection attacks, authentication bypasses, SSRF, business logic flaws, and insecure deserialization as any other language.

This guide covers the vulnerability classes that persist in Rust web applications, how to find them in code review and black-box testing, and specific patterns to probe in Actix-Web, Axum, and Rocket-based services.

What Rust Does and Does Not Protect

Vulnerability Class Rust Protection Notes
Buffer overflow Prevented (safe code) unsafe blocks can still overflow
Use-after-free Prevented (borrow checker) unsafe blocks bypass borrow checker
SQL injection None String formatting still works
SSRF None reqwest fetches any URL
XSS Partial Depends on template engine
Authentication bypass None Logic bugs are language-independent
Insecure deserialization None serde_json, bincode can be exploited
Integer overflow Partial Debug panics; release wraps silently

SQL Injection in Diesel and sqlx

Rust's most popular ORM (Diesel) and query library (sqlx) are safe when used with their typed query builders — but both expose raw query interfaces that developers use when the type system becomes inconvenient.

Diesel Raw Queries

// VULNERABLE — sql_query accepts a raw string
use diesel::sql_query;

let raw = format!("SELECT * FROM users WHERE username = '{}'", username);
let results: Vec<User> = sql_query(raw)
    .load::<User>(&conn)?;

// SECURE — use diesel's typed DSL
use schema::users::dsl::*;
let results = users
    .filter(username.eq(&input_username))
    .load::<User>(&conn)?;

sqlx Inline Queries

// VULNERABLE — string formatting inside query!()
let q = format!("SELECT * FROM users WHERE name = '{}'", name);
let rows = sqlx::query(&q).fetch_all(&pool).await?;

// SECURE — bound parameters
let rows = sqlx::query!("SELECT * FROM users WHERE name = $1", name)
    .fetch_all(&pool)
    .await?;

// Also SECURE — query_as with bind
let rows: Vec<User> = sqlx::query_as::<_, User>(
    "SELECT * FROM users WHERE name = $1"
).bind(&name).fetch_all(&pool).await?;

To find injection candidates during code review:

grep -rn "sql_query(format\|sql_query(&format\|query(&format" src/
grep -rn "format!(.*SELECT\|format!(.*INSERT\|format!(.*UPDATE" src/

SSRF via reqwest

The reqwest crate is the standard HTTP client for Rust. Like all HTTP clients, it will happily fetch any URL — including cloud metadata endpoints, internal services, and localhost — when given user-controlled input:

// VULNERABLE — fetch user-provided URL
async fn fetch_url(url: &str) -> Result<String, reqwest::Error> {
    let client = reqwest::Client::new();
    let body = client.get(url).send().await?.text().await?;
    Ok(body)
}

// reqwest follows redirects by default, resolves DNS at call time
// Test payloads:
// http://169.254.169.254/latest/meta-data/  (AWS)
// http://metadata.google.internal/          (GCP)
// file:///etc/passwd                        (if file scheme not disabled)
// http://[::1]/internal                     (IPv6 localhost)

reqwest Redirect Following

By default, reqwest::Client follows up to 10 redirects. This means an SSRF that initially hits an allowed domain can redirect to an internal address:

// Disable redirect following for URL-fetch functionality
let client = reqwest::Client::builder()
    .redirect(reqwest::redirect::Policy::none())
    .build()?;

// Or limit redirects and validate each hop
let client = reqwest::Client::builder()
    .redirect(reqwest::redirect::Policy::custom(|attempt| {
        // Validate destination before following
        if is_allowed(&attempt.url()) {
            attempt.follow()
        } else {
            attempt.stop()
        }
    }))
    .build()?;

Unsafe Blocks and Memory Corruption

Rust's safe code guarantees hold only outside of unsafe blocks. Any unsafe block in a web application is worth reviewing during a security assessment — not because unsafe code is necessarily wrong, but because it opts out of the compiler's guarantees.

grep -rn "unsafe {" src/
grep -rn "unsafe fn" src/
grep -rn "raw pointer\|*mut \|*const " src/
What to look for in unsafe blocks Pointer arithmetic on user-controlled lengths, slice::from_raw_parts with unvalidated size, transmute between types of different sizes, and FFI calls to C libraries with unchecked inputs.

Integer Overflow in Release Builds

Rust debug builds panic on integer overflow; release builds wrap silently. This matters for security-relevant calculations: array indices, allocation sizes, and length checks:

// In release mode, this wraps to 0 when u8 overflows:
let user_count: u8 = 255;
let next = user_count + 1;  // next = 0, not a panic

// For security-critical arithmetic, use checked_add / saturating_add
let next = user_count.checked_add(1).ok_or("overflow")?;
// Or: user_count.saturating_add(1)  // caps at u8::MAX

Deserialization Vulnerabilities

serde_json: Deeply Nested Structures

serde_json does not impose a default recursion limit. A deeply nested JSON structure can cause a stack overflow — a denial of service condition:

// Test payload — deeply nested JSON
{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":...}}}}}}}}}}

Mitigation: use a depth-limiting JSON parser or validate request body size before deserialisation. Actix-Web's web::Json extractor accepts a size limit via JsonConfig.

bincode and Arbitrary Deserialisation

Applications that deserialise untrusted bincode data from users are vulnerable to type confusion. Unlike serde_json (which uses a text format), bincode is a compact binary format where malformed input can trigger undefined behaviour in unsafe deserialisers:

// DANGEROUS — deserialising user-supplied bincode
let data: Vec<u8> = get_user_data();
let obj: MyStruct = bincode::deserialize(&data)?;

// Safer approach: validate and sanitise binary input
// before deserialisation, or use a text format (JSON/TOML)
// for untrusted inputs.

Framework-Specific Issues

Actix-Web: Extractor Order and Error Handling

Actix-Web extractors run in the order they appear in the handler signature. If an extractor panics rather than returning an error, the entire worker thread is affected. Check for unwrap() calls in extractor implementations or middleware:

grep -rn "\.unwrap()\|\.expect(" src/
# Focus on code paths that handle request data

Actix-Web's web::Json extractor returns a 400 by default if the body is malformed — but the error response may leak internal type information. Test what information is returned in error responses:

# Send malformed JSON to a POST endpoint
curl -X POST https://target.com/api/data \
  -H "Content-Type: application/json" \
  -d '{"broken": json'

# Send JSON with wrong types
curl -X POST https://target.com/api/data \
  -H "Content-Type: application/json" \
  -d '{"expected_int": "string_value"}'

Axum: Missing Authorization Middleware

Axum routes are built as a tree. Authorization middleware applied to one branch does not protect routes in other branches. During testing, map all routes and verify which middleware chain each route falls under:

// VULNERABLE — only /api/private routes are protected
let app = Router::new()
    .route("/api/users/me", get(get_current_user))  // unprotected
    .nest("/api/private", Router::new()
        .route("/admin", get(admin_handler))
        .layer(auth_middleware),  // only covers /api/private/*
    );

// SECURE — auth at the top level
let app = Router::new()
    .route("/api/users/me", get(get_current_user))
    .route("/api/private/admin", get(admin_handler))
    .layer(auth_middleware);  // covers everything above it

Rocket: Guard Bypass via Optional Guards

Rocket uses request guards for authentication. When a guard is defined as Option<Guard> rather than Guard, Rocket treats it as optional — unauthenticated requests still reach the handler, with the guard set to None:

// DANGEROUS — optional auth guard
#[get("/admin")]
fn admin(user: Option<AuthUser>) -> String {
    // user is None for unauthenticated requests
    // This route is accessible without authentication
    match user {
        Some(u) => format!("Welcome {}", u.name),
        None => "Please log in".to_string()  // still reached!
    }
}

// CORRECT — non-optional guard fails the request if unauthenticated
#[get("/admin")]
fn admin(user: AuthUser) -> String {
    // Rocket rejects the request before reaching this handler
    // if AuthUser guard fails
    format!("Welcome {}", user.name)
}

Path Traversal in File Operations

Rust's standard library provides Path::canonicalize, which resolves symbolic links and .. components. But canonicalize requires the path to exist — it fails on non-existent paths. A common pattern that looks safe but isn't:

use std::path::Path;

// UNSAFE PATTERN — canonicalize only works on existing paths
// so it's often checked only after the file is opened
fn serve_file(base: &Path, user_path: &str) -> std::io::Result<Vec<u8>> {
    let full_path = base.join(user_path);
    let canonical = full_path.canonicalize()?;
    // At this point, we've already opened the file
    // The check below is a TOCTOU issue
    if !canonical.starts_with(base) {
        return Err(std::io::Error::new(
            std::io::ErrorKind::PermissionDenied, "path escape"
        ));
    }
    std::fs::read(canonical)
}

// CORRECT PATTERN — check before opening
fn serve_file(base: &Path, user_path: &str) -> std::io::Result<Vec<u8>> {
    // Clean the path manually first
    let clean: std::path::PathBuf = user_path
        .split('/')
        .filter(|c| !c.is_empty() && *c != "." && *c != "..")
        .collect();
    let full_path = base.join(clean);
    // Verify it starts with base
    if !full_path.starts_with(base) {
        return Err(std::io::Error::new(
            std::io::ErrorKind::PermissionDenied, "path escape"
        ));
    }
    std::fs::read(full_path)
}

Template Injection in Tera and Askama

Tera (a Jinja2-like templating engine) auto-escapes HTML by default when configured to do so, but raw output is possible via the {{ var | safe }} filter. Search for this in templates:

grep -rn "| safe" templates/
grep -rn "autoescape: false" src/

Askama (compile-time templates) is generally safer because templates are type-checked at compile time. But raw HTML rendering via |safe still bypasses escaping.

Denial of Service via ReDoS and Panic

Rust panics abort the current task in async code (for most frameworks, this kills the request without taking down the server). But in synchronous Actix-Web workers, a panic kills the worker thread. Look for:

grep -rn "\.unwrap()\|\.expect(" src/ | grep -i "user\|input\|request\|param\|body\|header"

Security Testing Checklist for Rust Web Applications

Test What to Look For Method
SQL injection sql_query(format!), query(&format!) Code review, sqlmap
SSRF reqwest::get(user_url) Burp Collaborator, SSRF payloads
Unsafe blocks unsafe { } with user-controlled values grep, cargo audit
Integer overflow Arithmetic in release mode Overflow test inputs, cargo fuzz
Optional guards (Rocket) Option<AuthGuard> handler params Code review, unauthenticated requests
Missing auth (Axum) Routes outside auth middleware branch Route enumeration, middleware tree review
Path traversal base.join(user_path) without pre-check ../payload in path parameters
Panic / DoS unwrap() on user input in sync workers Malformed input, cargo fuzz

Automated Tools for Rust Security

# cargo-audit — check dependencies for known CVEs
cargo install cargo-audit
cargo audit

# cargo-deny — policy enforcement (license, advisories, sources)
cargo install cargo-deny
cargo deny check

# cargo-fuzz — coverage-guided fuzzing
cargo install cargo-fuzz
cargo fuzz init
cargo fuzz run fuzz_target_1

# Semgrep Rust rules
semgrep --config=p/rust .

# RustSec advisory database
cargo install cargo-audit
cargo audit --file Cargo.lock

Ironimo scans Rust, Go, and all major backend stacks using Kali Linux tools — the same tooling professional penetration testers use, available on-demand without the project overhead.

Start free scan
← Back to blog