Deep dive
Decision table
| Sink | Right API | Never |
|---|---|---|
| SQL values | Prepared statements / bound params ($1, ?) | format!, f-strings, string concat |
| SQL identifiers, ORDER BY | Allowlist map from user token to fixed SQL | Quoting user input as identifier |
| OS commands | Command::new(bin).arg(x), subprocess.run([...]) ; -- before user args | sh -c, shell=True, system() |
| HTML | Auto-escaping templates (askama, Jinja2 autoescape, React JSX) | innerHTML, dangerouslySetInnerHTML, the safe filter on user data |
| Rich text from users | Sanitizer with allowlist (ammonia, DOMPurify) | Regex-based stripping |
| File paths | Generated names (UUID) or canonicalize + prefix check; openat2(RESOLVE_BENEATH) / cap-std | Joining user filenames to a directory |
| URLs from users | Parse, allowlist scheme (https), SSRF guard | Fetching arbitrary URLs from servers |
| Structured input | Typed deserialization with size limits, deny_unknown_fields | Generic object deserialization of untrusted data |
| XML | Parser with DTDs/external entities disabled | Default old parsers (XXE) |
| Logs | Structured logging (JSON fields) | Writing raw user strings with newlines |
Code
Rust examples (sqlx = { version = "0.8", features = ["postgres", "runtime-tokio"] }, serde).
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::process::Command;
// 1. Parse at the boundary: types carry the validation forward.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateUser {
pub email: String,
pub display_name: String,
}
pub struct Email(String);
impl Email {
pub fn parse(s: &str) -> Result<Self, &'static str> {
let s = s.trim();
if s.len() > 254 || !s.contains('@') || s.chars().any(char::is_control) {
return Err("invalid email");
}
Ok(Email(s.to_ascii_lowercase()))
}
}
// 2. SQL: values bound, sort column from an allowlist.
pub async fn list_users(pool: &sqlx::PgPool, tenant: i64, sort: &str)
-> sqlx::Result<Vec<(i64, String)>> {
let order = match sort {
"name" => "display_name",
"created" => "created_at DESC",
_ => "id",
};
let sql = format!("SELECT id, display_name FROM users WHERE tenant_id = $1 ORDER BY {order} LIMIT 100");
sqlx::query_as(&sql).bind(tenant).fetch_all(pool).await
}
// 3. Processes: no shell, args separate, "--" ends option parsing
// so a pattern like "-r" or "--include=..." is not read as a flag.
pub fn search_file(pattern: &str, file: &Path) -> std::io::Result<String> {
let out = Command::new("/usr/bin/grep")
.arg("-F") // fixed string, no regex surprises
.arg("--")
.arg(pattern)
.arg(file)
.output()?;
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
// 4. Paths: resolve and confine.
pub fn confined(root: &Path, user_rel: &str) -> Option<PathBuf> {
let root = root.canonicalize().ok()?;
let p = root.join(user_rel).canonicalize().ok()?; // resolves "..", symlinks; file must exist
p.starts_with(&root).then_some(p)
}
For tools that don't support --, pass user paths as absolute paths so they can't start with -.
HTML output with askama auto-escapes {{ name }}; put user data only in text and quoted attribute contexts, never into <script> blocks or href without scheme checks.
Efficiency
Cost: validation is cheap CPU next to the I/O it guards; the real costs are hostile input sizes and wasted work on requests that will be rejected anyway. Bound by parsing and I/O, not compute.
Levers:
- Reject early: body, depth and array caps before parsing, cheap checks (length, type) before regexes or lookups — avoids all downstream work — any public endpoint.
- Bound parameters — let the driver reuse prepared statements; sqlx caches them per connection (
statement_cache_capacity) — every query. - Build-time templates — askama compiles templates to Rust, so auto-escaping adds no runtime parsing — server-rendered HTML.
- Linear-time regex — Rust's
regexguarantees search time linear in the input, so ReDoS cannot burn a core — any user-supplied pattern or text. - Sanitize once on write — store sanitized rich text, re-run only when the policy changes — rich-text content.
Naive → standard → state of the art:
- Naive: hand-built query strings, backtracking regexes, validation after parsing · exploitable and unbounded work per request
- Standard: bound parameters, size caps first, escape-on-output · near-zero overhead
- State of the art: typed parsing at the edge, linear-time regex, sanitize-on-write; plain-text escaping or UUID file names where rich text or user paths are not needed · least work per request
How to validate
-
Validate type, length, range and format; reject, don't "fix".
-
Canonicalize first (Unicode NFC/NFKC, trim, lowercase where semantic), then validate, then use.
-
Cap sizes everywhere: request body (order of 1 MiB default), JSON depth, array length, upload size, regex input length.
-
Regexes on untrusted input: use linear-time engines (Rust
regex, RE2) to avoid ReDoS. -
Treat LLM output as untrusted input: it can carry injection payloads from documents it read.
-
Errors: reject with a generic message and log the detail server-side; never echo raw input into error pages.
Pitfalls
- Escaping by hand (
replace("'", "''")) instead of binding; breaks on encodings and edge cases. - ORMs with raw-query escape hatches (
.raw(),whereRaw,text()) with interpolated input. - Validating on the client only.
- Blocklists (
<script>,../): bypassed by encodings, case,..%2f,....//. canonicalize+ check, then opening later: a symlink swapped in between (TOCTOU). Open relative to a directory handle (cap-std) when attackers can write in the tree.- Zip/tar extraction without path checks ("zip slip").
- XSS via
javascript:URLs inhref, or user data in inline event handlers. - Template engines compiling user input as a template (server-side template injection).
- Header injection via CR/LF in values placed into HTTP headers or emails.
In production
Injection flaws are exploited across fields:
- Telecom: the 2015 TalkTalk breach used SQL injection in legacy web pages (156,959 customers' data accessed; £400,000 ICO fine).
- Cloud infrastructure, gaming, consumer services: Log4Shell (user-controlled log strings triggered JNDI lookups) affected AWS, Cloudflare, iCloud, Minecraft: Java Edition and Steam.
- Credit reporting: the 2017 Equifax breach followed an unpatched Apache Struts flaw (CVE-2017-5638) that let a crafted
Content-Typeheader run commands.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| sqlx | Rust | transact-rs/sqlx | cargo add sqlx | — | — | — | — | bound parameters, statement cache; Postgres, MySQL, SQLite |
| askama | Rust | askama-rs/askama | cargo add askama | — | — | — | — | compile-time templates, auto-escaping by default |
| ammonia | Rust | rust-ammonia/ammonia | cargo add ammonia | — | — | — | — | allowlist HTML sanitizer |
| cap-std | Rust | sunfishcode/cap-std | cargo add cap-std | — | — | — | — | directory-handle file access, no path escape |
| DOMPurify | JavaScript | cure53/DOMPurify | npm install dompurify | — | — | — | — | browser-side HTML sanitizer |
Sources
- OWASP, SQL Injection Prevention Cheat Sheet. doc
- OWASP, Cross Site Scripting Prevention Cheat Sheet. doc
- OWASP, OS Command Injection Defense Cheat Sheet. doc
- OWASP, Deserialization Cheat Sheet. doc
- OWASP, Input Validation Cheat Sheet. doc
- MITRE, CWE-89, CWE-78, CWE-79, CWE-22 entries. CWE-89 · CWE-78 · CWE-79 · CWE-22
- King, Parse, don't validate, 2019. post