Security & Supply Chain
Prerequisites: Testing strategies
Safe Rust eliminates whole classes of vulnerabilities, but not all of them. Knowing exactly where the guarantee ends is what keeps Rust services secure.
What memory safety does and doesn't give you
In safe Rust, the compiler rules out:
- use-after-free, double free and dangling pointers,
- buffer overflows (indexing is bounds-checked),
- data races between threads,
- reading uninitialised memory.
It does not protect against:
- logic bugs and missing authorisation checks,
- injection (SQL, shell, HTML) — that is about how you build strings,
- denial of service: panics, unbounded memory growth, slow algorithms,
- deadlocks and memory leaks (
std::mem::forgetandRccycles are safe), - bugs in
unsafecode, including in your dependencies.
Integer overflow
In debug builds, arithmetic overflow panics. In release builds it wraps silently by default. When a number comes from user input and feeds a price, a length or an allocation size, choose the behaviour explicitly:
fn main() {
let price: u32 = 4_000_000_000;
let qty: u32 = 2;
println!("{:?}", price.checked_mul(qty)); // None on overflow
println!("{}", price.saturating_mul(qty)); // clamps to u32::MAX
println!("{}", price.wrapping_mul(qty)); // explicit wrap-around
// `as` truncates silently; try_from reports the problem.
let big: i64 = 300;
println!("{}", big as u8);
println!("{:?}", u8::try_from(big).is_err());
}
Expected output:
None
4294967295
3705032704
44
true
To keep overflow panics in release builds too, add this to Cargo.toml. It
costs a little speed and turns silent corruption into a loud failure:
[profile.release]
overflow-checks = true
Panics are a denial of service
unwrap(), expect(), indexing out of bounds and division by zero all
panic. In a CLI that's a crash with a message; in a server, a panic in a
handler kills that request's task and the client gets a dropped connection.
Rules for request-handling code:
- Treat all input as untrusted. Parse with
?and return4xxerrors rather thanunwrap(). - Use
.get(i)instead of[i]on indices that come from input. - Bound everything: request body size (Axum's
DefaultBodyLimit, 2 MB by default), collection sizes, string lengths, recursion depth, timeouts. - As a backstop,
tower_http::catch_panic::CatchPanicLayerturns a panic into a500response.
Reserve unwrap() for invariants that truly cannot fail, and prefer
expect("why this can't fail") so the reason is recorded.
Supply chain: auditing dependencies
A typical service pulls in hundreds of crates. Each one runs with your privileges, and build scripts and proc macros run on your build machine.
cargo auditchecksCargo.lockagainst the RustSec advisory database of known vulnerabilities:
cargo install cargo-audit
cargo audit
cargo denyenforces policy: advisories, allowed licenses, banned or duplicate crates, and allowed sources.cargo deny initwrites a starterdeny.toml; a trimmed version looks like:
[advisories]
version = 2
yanked = "deny"
[licenses]
version = 2
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "Unicode-3.0"]
[bans]
multiple-versions = "warn"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
Run cargo deny check in CI so a new vulnerable or unapproved dependency
fails the build.
- Commit
Cargo.lockfor applications so builds are reproducible, and runcargo updatedeliberately (and with tests), not by accident. - Before adding a crate, look at its maintenance, download count, and how
much
unsafeand how many dependencies it brings in (cargo tree).
Minimising unsafe
Forbid unsafe in crates that don't need it, so it can't creep in:
[lints.rust]
unsafe_code = "forbid"
Where unsafe is genuinely needed, isolate it in one small module with
SAFETY comments, tests, and Miri in CI.
Handling secrets
- Load secrets from environment variables or a secret manager at runtime. Never commit them, bake them into Docker images, or pass them as command-line arguments (visible in process listings).
- Keep them out of logs. A derived
Debugon a config struct prints every field, so redact:
use std::fmt;
#[allow(dead_code)] // in a real app, the fields are read elsewhere
struct Secret(String);
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret([REDACTED])")
}
}
#[allow(dead_code)]
#[derive(Debug)]
struct Config {
database_host: String,
api_key: Secret,
}
fn main() {
let config = Config {
database_host: "db.internal".into(),
api_key: Secret("sk_live_123".into()),
};
println!("{config:?}");
}
Expected output:
Config { database_host: "db.internal", api_key: Secret([REDACTED]) }
The secrecy crate packages this pattern (SecretString) and zeroes the
memory on drop.
- Compare tokens and MACs in constant time (the
subtlecrate), and hash passwords with a password hashing function such as Argon2 (argon2crate), never a plain SHA-256.
Common mistakes
- Assuming "it's Rust, so it's secure" and skipping input validation and authorisation checks.
unwrap()on request data in handlers.ascasts on untrusted numbers (len as u32) that silently truncate.- Never running
cargo audit, or ignoring its output indefinitely. - Logging whole request bodies or config structs that contain credentials.
Practice
Write parse_quantity(input: &str) -> Result<u32, String> that accepts only
integers from 1 to 1000, and line_total(price_cents: u32, qty: u32) -> Option<u32>
that returns None on overflow. Print the results for "3", "0", "abc"
and a line total of 4_000_000_000 cents × 2.
Show solution
fn parse_quantity(input: &str) -> Result<u32, String> {
let n: u32 = input
.trim()
.parse()
.map_err(|_| format!("not a number: {input:?}"))?;
if (1..=1000).contains(&n) {
Ok(n)
} else {
Err(format!("quantity out of range: {n}"))
}
}
fn line_total(price_cents: u32, qty: u32) -> Option<u32> {
price_cents.checked_mul(qty)
}
fn main() {
println!("{:?}", parse_quantity("3"));
println!("{:?}", parse_quantity("0"));
println!("{:?}", parse_quantity("abc"));
println!("{:?}", line_total(4_000_000_000, 2));
}
This prints:
Ok(3)
Err("quantity out of range: 0")
Err("not a number: \"abc\"")
None