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/
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:
- Regex patterns applied to user input without length limits — compile the pattern with a timeout or use a linear-time engine
- Recursive deserialization without depth limits
unwrap()orexpect()on user-controlled values — these will panic on bad input- Allocation of user-specified sizes without bounds checking
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