53. Parsing untrusted input: newtypes, "parse, don't validate", resource bounds
53.1 Parse, don't validate
A validate(&str) -> bool function leaves the value as a String, and
nothing stops the next developer from skipping the call. Parsing turns
untrusted input into a type that can only hold valid values. After the
boundary, the type is the proof that validation happened.
use serde::Deserialize;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
#[serde(try_from = "String")] // deserialize a String, then run TryFrom on it
pub struct Username(String); // private field: the only way to make one is TryFrom
impl TryFrom<String> for Username {
type Error = String;
fn try_from(s: String) -> Result<Self, String> {
let ok_len = (3..=32).contains(&s.len());
let ok_chars = s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_');
let ok_start = s.bytes().next().is_some_and(|b| b.is_ascii_lowercase());
if ok_len && ok_chars && ok_start { Ok(Username(s)) } else { Err(format!("invalid username {s:?}")) }
}
}
impl fmt::Display for Username {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) }
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SignupRequest {
username: Username, // validated during deserialization
#[serde(default)]
marketing_opt_in: bool,
}
fn create_home_dir(user: &Username) -> String {
// Path traversal ("../../etc") is impossible here: the TYPE guarantees [a-z0-9_] only.
format!("/home/{user}")
}
fn main() {
for body in [
r#"{"username":"ada_l"}"#,
r#"{"username":"../../etc/passwd"}"#,
r#"{"username":"Ada"}"#,
r#"{"username":"ada_l","is_admin":true}"#,
] {
match serde_json::from_str::<SignupRequest>(body) {
Ok(req) => println!("ok -> {} (opt-in {})", create_home_dir(&req.username), req.marketing_opt_in),
Err(e) => println!("rejected: {e}"),
}
}
}
ok -> /home/ada_l (opt-in false)
rejected: invalid username "../../etc/passwd" at line 1 column 31
rejected: invalid username "Ada" at line 1 column 18
rejected: unknown field `is_admin`, expected `username` or `marketing_opt_in` at line 1 column 30
The same idea works for every input that matters: Email, TenantId,
NonEmptyVec, Port, Cents(u64) instead of f64 for money, and an
HtmlEscaped type that only an escaping function can produce. When
create_home_dir takes &Username instead of &str, the compiler stops
any unvalidated string from reaching it.
53.2 Bounds on every resource
Untrusted input controls how much memory and CPU your parser uses unless you bound it. Check each of these:
| Resource | Bound | Where you have seen it |
|---|---|---|
| Body size | RequestBodyLimitLayer, a MAX_FRAME check |
§32.2, §47 |
| Header size | max_buf_size, MAX_HEAD |
§46, §50 |
| Length fields | compare to a maximum before allocating | §32.2 |
| Nesting depth | serde_json stops at 128 levels by default; keep that limit |
— |
| Collection counts | Vec::with_capacity(n) with n taken from input is an allocation bomb; cap n |
§45 (DNS counts are not trusted) |
| Regex | the regex crate guarantees linear time (no ReDoS) |
§26 |
| Decompression | cap output bytes, not input bytes (zip bombs) | — |
| Time | timeouts on reads, handlers, and outbound calls | §31, §47, §48 |
53.3 Injection: let the types build the query
// fragment: sqlx (async, compile-time checked SQL)
// BUG: SQL injection. Rust's safety does not help with string formatting.
let q = format!("SELECT * FROM users WHERE email = '{email}'");
sqlx::query(&q).fetch_one(&pool).await?;
// FIX: a bound parameter. The value never becomes SQL text.
sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
.bind(&email)
.fetch_one(&pool)
.await?;
// sqlx::query! checks the SQL against your real schema AT COMPILE TIME.
let user = sqlx::query!("SELECT id, email FROM users WHERE email = $1", email).fetch_one(&pool).await?;
Security from Zero Ch 42
covers injection in general. The Rust-specific point is that format! is
string concatenation, so the language does not save you from SQL injection.
APIs that take parameters separately (sqlx, Command::arg in §24.3, and
auto-escaping templates such as askama) do.