Skip to content
AgileLogics
8 min read

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::forget and Rc cycles are safe),
  • bugs in unsafe code, 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 return 4xx errors rather than unwrap().
  • 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::CatchPanicLayer turns a panic into a 500 response.

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 audit checks Cargo.lock against the RustSec advisory database of known vulnerabilities:
cargo install cargo-audit
cargo audit
  • cargo deny enforces policy: advisories, allowed licenses, banned or duplicate crates, and allowed sources. cargo deny init writes a starter deny.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.lock for applications so builds are reproducible, and run cargo update deliberately (and with tests), not by accident.
  • Before adding a crate, look at its maintenance, download count, and how much unsafe and 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 Debug on 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 subtle crate), and hash passwords with a password hashing function such as Argon2 (argon2 crate), 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.
  • as casts 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