flawopen.com/sql-injection/Rust
SQL Injection in Rust (rusqlite, sqlx, Diesel)
Memory safety does not stop SQL injection: format!() into a rusqlite, sqlx or Diesel query lets a request parameter rewrite your SQL and return every row (CWE-89). The bound form for each crate, when sqlx's query! macro helps, and how to choose ORDER BY columns with a match.
Imagine a workshop where each job ticket has a fixed instruction and a separate numbered slot for the customer's value. If you use Rust's format!() to paste the value into the instruction text, the database receives one new string and can read the pasted text as SQL grammar. Rust's ownership and type checks do not inspect SQL meaning inside that String. rusqlite params![], sqlx bind(), and Diesel .bind() keep values in parameter slots; choose sort-column names from fixed &'static str options because SQL identifiers cannot be bound as values.
Core Concepts & Subsystem Terms
format!() into SQLformat!("... = {user_id}")builds an ownedStringwith the value already inside. Every Rust SQL crate accepts&str, so the compiler has no way to tell a finished query from a template. The borrow checker protects memory, not query structure.rusqlite params![]params supplies values for?1,?2after SQLite compiles the statement. rusqlite'sexecuteandquery_rowrun a single statement;execute_batchruns several and takes no parameters, so never pass it input.sqlx bind() and query!sqlx::query(sql).bind(value)sends values separately; PostgreSQL uses$1, MySQL and SQLite use?. Thequery!andquery_as!macros go further: they check the SQL against the database at compile time and require the query to be a string literal, soformat!cannot even compile.Diesel sql_query().bind()sql_queryruns raw SQL, and.bind::<Text, _>(value)attaches a typed value to$1. Diesel's query builder (users.filter(email.eq(value))) binds automatically and should be the default.&'static str column names- Identifiers cannot be bound. Matching the requested key to a
&'static strstatement means only strings compiled into the binary can reach the database; the type makes the allowlist explicit.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-89
- OWASP Top 10:2025 — A05:2025 Injection
Step-by-Step Attack Flow
Request value reaches the handler
An axum or actix-web handler extracts Query<HashMap<String, String>> and passes params["id"] as &str to find_email(). No parse::<i64>() runs.
format! builds the SQL
format!("... WHERE id = {user_id}") interpolates at runtime. With the benign test value 0 OR 1=1, the string reads WHERE id = 0 OR 1=1.
The crate receives SQL and no parameters
query_row(&sql, [], ...) has an empty parameter list, so SQLite parses OR 1=1 as part of the WHERE clause. The borrow checker and type system see nothing wrong.
Rows the caller should not see come back
The condition matches every row; query_map or fetch_all in a list endpoint returns the whole table. The sqlx and Diesel versions behave identically.
Source Code: Flaw vs. Secure Implementation
// users.rs: each query below formats request data into the SQL text
use diesel::prelude::*;
use diesel::sql_types::{BigInt, Text};
use rusqlite::Connection;
use sqlx::PgPool;
#[derive(QueryableByName)]
pub struct UserRow {
#[diesel(sql_type = BigInt)]
pub id: i64,
#[diesel(sql_type = Text)]
pub email: String,
}
// rusqlite: format! builds the finished statement at runtime
pub fn find_email(conn: &Connection, user_id: &str) -> rusqlite::Result<String> {
let sql = format!("SELECT email FROM users WHERE id = {user_id}");
conn.query_row(&sql, [], |row| row.get(0))
}
// sqlx: the runtime query() accepts any &str, including this one
pub async fn count_orders(pool: &PgPool, status: &str) -> sqlx::Result<i64> {
let sql = format!("SELECT count(*) FROM orders WHERE status = '{status}'");
sqlx::query_scalar(&sql).fetch_one(pool).await
}
// Diesel: sql_query with format! is raw, unbound SQL
pub fn find_by_email(conn: &mut PgConnection, email: &str) -> QueryResult<Vec<UserRow>> {
diesel::sql_query(format!("SELECT id, email FROM users WHERE email = '{email}'")).load(conn)
}
// ORDER BY column taken straight from the request
pub fn list_emails(conn: &Connection, sort_column: &str) -> rusqlite::Result<Vec<String>> {
let mut stmt = conn.prepare(&format!("SELECT email FROM users ORDER BY {sort_column}"))?;
let rows = stmt.query_map([], |row| row.get(0))?;
rows.collect()
}
// users.rs: values are bound, statements are compile-time constants
use diesel::prelude::*;
use diesel::sql_types::{BigInt, Text};
use rusqlite::{params, Connection};
use sqlx::PgPool;
#[derive(QueryableByName)]
pub struct UserRow {
#[diesel(sql_type = BigInt)]
pub id: i64,
#[diesel(sql_type = Text)]
pub email: String,
}
// rusqlite: ?1 compiled first, value supplied by params!
pub fn find_email(conn: &Connection, user_id: i64) -> rusqlite::Result<String> {
conn.query_row("SELECT email FROM users WHERE id = ?1", params![user_id], |row| row.get(0))
}
// sqlx: $1 filled in by PostgreSQL from bind()
pub async fn count_orders(pool: &PgPool, status: &str) -> sqlx::Result<i64> {
sqlx::query_scalar::<_, i64>("SELECT count(*) FROM orders WHERE status = $1")
.bind(status)
.fetch_one(pool)
.await
}
// Diesel: typed bind attaches the value to $1
pub fn find_by_email(conn: &mut PgConnection, email: &str) -> QueryResult<Vec<UserRow>> {
diesel::sql_query("SELECT id, email FROM users WHERE email = $1")
.bind::<Text, _>(email)
.load(conn)
}
// match returns one of three &'static str statements
pub fn list_emails(conn: &Connection, sort_key: &str) -> rusqlite::Result<Vec<String>> {
let sql: &'static str = match sort_key {
"email" => "SELECT email FROM users ORDER BY email",
"created" => "SELECT email FROM users ORDER BY created_at",
_ => "SELECT email FROM users ORDER BY id",
};
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([], |row| row.get(0))?;
rows.collect()
}
Engineering & System Hardening Checklist
- ✓Prefer sqlx
query!/query_as!so SQL must be a literal checked at build time; in CI, runcargo sqlx prepare --checkto keep offline query data current. - ✓Add a Semgrep rule or a review check that rejects
format!,concat!orpush_stroutput passed toquery,query_row,execute,prepare,sql_queryorexecute_batch. - ✓Pass every value through
params![],.bind()or Diesel's query builder, and parse IDs intoi64/Uuidin the extractor so malformed input returns 400. - ✓Choose ORDER BY with a
matchthat returns&'static strstatements or Diesel column expressions; never interpolate a requested column. - ✓Never pass input to
execute_batch, and connect with a database role that has no DDL rights.