Skip to content
rusthacks.dev
Security

Security for Rust Developers a Practical Threat Model

A practical threat model for Rust code: what the compiler prevents, what it does not, and the checklist of supply chain, input handling, secrets, cryptography and deployment risks.

14 min read
Summarize in AI
Flat illustration for the post “Security for Rust Developers a Practical Threat Model”, composed around tooling.

Rust removes one large family of security bugs, and people sometimes hear that as "Rust programs are secure". They are not, and a threat model is how you find out which risks remain. The compiler stops memory corruption in safe code: an out-of-bounds read becomes a panic, a use-after-free does not compile. It does nothing about a client who asks for a file outside the document root, a request body that takes a gigabyte to parse, a peer-supplied length that overflows, or an API key printed into a log. This guide builds a practical threat model for a Rust network service and then tests each threat with a small program, so the claim "this is a real risk" and the claim "this is the fix" both come with output. Everything ran on rustc 1.99.0 with serde_json 1, regex 1 and subtle 2, on an Apple M1 Max. The demonstrations are written as examples that attack themselves; none touches a real system.

What a threat model is for

A threat model is four questions answered in writing: what are we protecting, who might attack it, where can they reach it, and what happens when they do. The output is not a document to file; it is a short list of threats ranked by how bad they are and how easy they are to exploit, with a decision on each (fix, accept, or move elsewhere). For a service written in Rust the answers are usually the same shape:

  • Assets. The data in the database, the credentials the service holds, and its availability. For many services availability is the asset that attackers can reach most cheaply.
  • Attacker. An anonymous user of the public API, an authenticated user trying to read another user's data, or a compromised dependency. Decide which you defend against; "everyone" is not a design.
  • Entry points. Every place bytes enter: the HTTP request line, headers, query string and body, file uploads, message queues, configuration files, and the environment. Anything a client controls is hostile until validated.
  • Trust boundaries. The line between data you produced and data you received. The most useful habit is to ask, at every function that takes a parameter, which side of the line the value came from.

The STRIDE checklist (spoofing, tampering, repudiation, information disclosure, denial of service, elevation of privilege) is a reasonable prompt for the "what can go wrong" step. Map each category to an entry point and write one sentence. The sections below take the threats that Rust code meets most often and show one of each.

ThreatSTRIDE classDoes safe Rust prevent it?Section
Buffer overflow, use-after-freeElevation of privilegeYes, in safe codeBelow, boundary notes
Path traversalInformation disclosureNoPaths
Oversized or deeply nested inputDenial of serviceNoResource limits
Panic on bad inputDenial of serviceNo, it causes onePanics
Integer overflow in sizesTampering, denial of serviceNot in release buildsArithmetic
Secrets in logsInformation disclosureNoSecrets
Vulnerable dependencyAnyNoDependencies

Threat one: the client chooses the path

A file server that takes a name from the URL and joins it onto a directory is the classic way to hand out files you never meant to. The mistake is easy to write, because the join looks correct. Here is the naive version next to a version that rejects anything that is not a plain name and then confirms the resolved path is still under the root. The demo creates a public directory with one file and a secret.txt beside it:

examples/t1_path.rsrust
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};

/// The obvious version: trusts the client's relative path.
fn naive(root: &Path, requested: &str) -> PathBuf {
    root.join(requested)
}

/// Rejects anything that is not a plain name, then checks the result stays under the root.
fn safe(root: &Path, requested: &str) -> io::Result<PathBuf> {
    let rel = Path::new(requested);
    if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
        return Err(io::Error::new(io::ErrorKind::InvalidInput, "path must be relative and contain no .. or root"));
    }
    let full = root.join(rel).canonicalize()?;
    if !full.starts_with(root.canonicalize()?) {
        return Err(io::Error::new(io::ErrorKind::PermissionDenied, "escapes the document root"));
    }
    Ok(full)
}

The loop in main asks both functions for three names: a legitimate one, a parent-directory escape and an absolute path. This is what came back:

Text

naive  readme.txt     -> public/readme.txt        reads: public fil
safe   readme.txt     -> ok, reads: public file
naive  ../secret.txt  -> public/../secret.txt     reads: TOP SECRET
safe   ../secret.txt  -> rejected: path must be relative and contain no .. or root
naive  /etc/hosts     -> /etc/hosts               reads: ## # Host 
safe   /etc/hosts     -> rejected: path must be relative and contain no .. or root

The naive version served TOP SECRET for ../secret.txt and the start of /etc/hosts for the absolute path. That second one is a subtle point: Path::join with an absolute argument replaces the base entirely, so there is no .. to filter for. A check that only strips ../ would have missed it. The safe version rejects both because its test is structural, not textual: every component of the requested path must be a Component::Normal, a plain name, which excludes .., a root, a drive prefix and a bare .. The second check, canonicalize followed by starts_with, is the one that survives symbolic links: if the document root contains a link that points outside, the canonical path will not start with the root and the request is refused. Do both. The first check gives a clear error; the second is the proof.

The residual risk is a race: the file system can change between the check and the open (a time-of-check to time-of-use gap). For an untrusted upload directory, open the file first and then verify, or use a capability-based API such as the cap-std crate, which makes escaping the directory unrepresentable.

Threat two: input that is expensive to read

Memory safety does not bound resource use. Three kinds of input each cost the server more than they cost the sender, and each was measured here:

examples/t2_limits.rsrust
use std::time::Instant;

fn main() {
    // 1. Deeply nested JSON: the parser has a recursion limit, so this is an error, not a stack overflow.
    let depth = 100_000;
    let nested = format!("{}{}", "[".repeat(depth), "]".repeat(depth));
    match serde_json::from_str::<serde_json::Value>(&nested) {
        Ok(_) => println!("nested {depth} deep: parsed"),
        Err(e) => println!("nested {depth} deep: {e}"),
    }

    // 2. The limit is on nesting, not on size: a 50 MB flat array is accepted unless the caller caps the body.
    let big = format!("[{}0]", "0,".repeat(25_000_000));
    let t = Instant::now();
    let v: serde_json::Value = serde_json::from_str(&big).unwrap();
    println!("flat array of {} items, {} MB body: parsed in {} ms", v.as_array().unwrap().len(), big.len() / 1_000_000, t.elapsed().as_millis());

    // 3. A regex that is catastrophic in a backtracking engine runs in linear time here.
    let re = regex::Regex::new(r"^(a+)+$").unwrap();
    let hostile = format!("{}!", "a".repeat(50_000));
    let t = Instant::now();
    let m = re.is_match(&hostile);
    println!("(a+)+$ against {} chars: match={m} in {} ms", hostile.len(), t.elapsed().as_millis());
}
Text
nested 100000 deep: recursion limit exceeded at line 1 column 128
flat array of 25000001 items, 50 MB body: parsed in 266 ms
(a+)+$ against 50001 chars: match=false in 0 ms

Each line is a lesson. The first: serde_json refuses to recurse past 128 levels, so a 200,000-byte request of nothing but brackets is rejected with an error at column 128 and never overflows the stack. That default is why a parser written in safe Rust does not crash on this input, and it is the reason to prefer a library parser to a hand-written recursive one. The second is the one that bites. The parser has no limit on size, so a 50 MB flat array was accepted, and the whole process peaked at 1,442,332,672 bytes of resident memory, about 1.4 GB, for that single value. The input cost the sender 50 MB; it cost the server nearly thirty times that. The defence is not in the parser, it is at the edge: cap the body before you read it (for example 1 MB for a typical JSON API) and reject with 413. The third line shows what the regex crate does with a pattern that is catastrophic in a backtracking engine such as those in many scripting languages. It uses finite automata, so the 50,001-character hostile string took under a millisecond. That is a guarantee of the crate and not of every library: if you choose a backtracking regex engine or a crate that supports look-around and back-references, put a timeout or a size limit around it.

Size is only one axis of cost; time is the other. A client that opens a connection and sends one byte a minute holds a task, a socket and its buffers for as long as you let it. Set a deadline on reading the request head and on reading the body, a ceiling on concurrent connections, and a limit per client address. In an async server these are one wrapper around each read, such as tokio::time::timeout, and they are cheap to add and easy to forget. A threat model is where you notice they are missing: the question "what does one connection cost us if the other side never speaks again" has a single honest answer, and it should not be "a worker forever".

Threat three: a panic is a denial of service

In safe Rust an out-of-bounds index cannot read memory it should not, and that is the good news. The bad news is what it does instead: it panics, and a panic that a client can trigger on demand is an outage. If the panic unwinds the whole thread that serves a connection, one request can take down a worker; with panic = "abort" it takes down the process. The usual source is a length or an index that came from the peer, used to slice a buffer you hold:

examples/t2_limits.rs (part 4)rust
let data = vec![1u8; 16];
let claimed_len: usize = 4096;
let r = std::panic::catch_unwind(|| data[..claimed_len].len());
println!("slice of claimed length {claimed_len} from a 16 byte buffer: panicked = {}", r.is_err());
let safe = data.get(..claimed_len).map(|s| s.len());
println!("same read with .get(): {safe:?}");
Text
thread 'main' panicked at examples/t2_limits.rs:28:45:
range end index 4096 out of range for slice of length 16
slice of claimed length 4096 from a 16 byte buffer: panicked = true
same read with .get(): None

The indexing form panicked. .get(..n) on the same slice returned None, which the caller can turn into a 400 response. The rule that follows is simple and worth enforcing in code review: any index, slice bound or unwrap() whose operand traces back to the network is a bug until it is a .get() or a checked conversion. catch_unwind, shown above, is a backstop at the request boundary, not a fix: it only works with panic = "unwind", it does not make state consistent after a panic, and it hides the bug. Web frameworks usually catch panics per request for you. Use that to keep the process alive, log the panic loudly, and fix the cause.

Threat four: arithmetic that wraps

A debug build panics on integer overflow and a release build wraps, silently, unless you opt in to checks. That is a security difference as well as a correctness one: the build you test is not the build you ship. The classic case is a size computed from two fields the peer sent. Say a message announces a count and an item size, and the server allocates count times size bytes. Multiply in 32 bits and the product can be tiny:

examples/t3_misc.rs (part 1)rust
/// Total size of a message built from a count and an item size sent by the peer.
fn total_wrapping(count: u32, item: u32) -> u32 {
    count.wrapping_mul(item)
}

fn total_checked(count: u32, item: u32) -> Result<u32, &'static str> {
    count.checked_mul(item).ok_or("size overflows u32")
}
Text
wrapping: 65536 x 65537 = 65536 bytes -> buffer of 65536 bytes for 4295032832 bytes of data
checked : Err("size overflows u32")

A peer that announces 65,536 items of 65,537 bytes means 4,295,032,832 bytes of data. Wrapped into 32 bits, that is 65,536: the server would allocate 64 KiB and then copy four gigabytes into the loop that follows, or trust a size that no longer matches the data. In safe Rust the copy would hit a bounds check and panic, so the failure is again denial of service rather than corruption; in code with unsafe it is an exploit. The fix is to make overflow a value you handle: checked_mul returns None, which becomes an error. Widen to u64 before multiplying, then compare against a ceiling you chose, such as 1 MiB per message. Setting overflow-checks = true in [profile.release] turns every unchecked + and * into a panic in the shipped build; that is a reasonable default for a network service because a panic is preferable to a wrong size.

Threat five: secrets that reach the log

#[derive(Debug)] prints every field, and a struct that holds a credential becomes a credential printer the first time anyone writes {:?} in a log line or an error message. The fix is a wrapper type whose Debug implementation says nothing:

examples/t3_misc.rs (part 2)rust
/// A secret that cannot leak through {:?}, logs or error chains.
struct Secret(String);
impl fmt::Debug for Secret {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("Secret(<redacted>)")
    }
}

#[derive(Debug)]
struct Leaky {
    user: String,
    api_key: String,
}

#[derive(Debug)]
struct Careful {
    user: String,
    api_key: Secret,
}
Text
Leaky { user: "ana", api_key: "sk_live_12345" }
Careful { user: "ana", api_key: Secret(<redacted>) }

The derived output prints the key in the clear; the wrapped one prints a placeholder, and the struct that contains it still derives Debug without the author remembering to hide anything. This works because the safe behaviour is in the type, so it applies everywhere the type travels. The secrecy crate provides a hardened version of the same idea (it also zeroes the memory on drop). The wider rule is to keep secrets out of URLs, error messages and tracing fields, and to review log output in staging for strings that look like keys.

A related habit is comparing secrets. The == operator on byte slices can stop at the first differing byte, so in principle the time it takes reveals how many leading bytes were right. The subtle crate's ct_eq compares in constant time:

examples/t3_misc.rs (part 3)rust
use subtle::ConstantTimeEq;

let expected = b"correct-horse-battery";
let guess = b"correct-horse-batterz";
println!("== : {}", expected == guess);
println!("ct : {}", bool::from(expected.ct_eq(guess)));
Text
== : false
ct : false

Both return false, as they must, so the output proves only that the API works. I did not measure the timing difference, and I will not claim one: over a network the signal is small and noisy, and for a short comparison on modern hardware it is difficult to observe at all. Use ct_eq for tokens, MACs and password hashes anyway, because it is free and removes a class of argument. For passwords themselves use a password-hashing crate (argon2) and let it do the comparison.

Threat six: the code you did not write

A typical service has several hundred transitive dependencies, and a vulnerability in any of them is yours. The tooling is cheap. cargo deny check advisories and cargo audit read the RustSec advisory database and fail when a locked version has a known issue. On this small demonstration tree (serde_json, regex, subtle and their dependencies) the check printed advisories ok. That result is a statement about a few dozen crates on one day and says nothing about your service; the useful part is the habit, which is to run it in CI, and to run it on a schedule as well, because a new advisory can appear against code that has not changed. Add cargo deny check licenses bans sources to control which crates and registries you accept, and pin your toolchain so a compiler update is a deliberate event.

For your own code, the strongest single control is to say where unsafe is allowed. A crate-level attribute makes any use of it a compile error:

examples/t4_forbid.rsrust
#![forbid(unsafe_code)]

fn main() {
    let v = vec![1u8, 2, 3];
    let x = unsafe { *v.as_ptr().add(3) };
    println!("{x}");
}
Text
error: usage of an `unsafe` block
 --> examples/t4_forbid.rs:5:13
  |
5 |     let x = unsafe { *v.as_ptr().add(3) };
  |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^

Put #![forbid(unsafe_code)] at the top of every application crate that has no business using it, and let the exceptions live in one small, reviewed crate. forbid cannot be overridden by an inner #[allow], unlike deny. A reviewer can then say with confidence that the safe-Rust guarantees apply to this crate, and a dependency that uses unsafe is the one place to look for the rest.

A short list to put in the repository

  1. Cap every body and every field length at the edge, before parsing. Measure the memory cost of the largest accepted input.
  2. Treat any index, slice bound, unwrap() or expect() on network-derived data as a bug; use .get(), try_from and typed errors.
  3. Use checked arithmetic for sizes, widen before multiplying, and turn on overflow-checks in release for services.
  4. Validate paths by structure and by canonical prefix, or use a capability library.
  5. Wrap secrets in a type that redacts itself; compare them in constant time.
  6. Run cargo deny (or cargo audit) on every pull request and nightly, and forbid unsafe where it is not needed.

Where this model stops

  • It covers the code, not the system. Authentication design, authorisation checks on every object, TLS configuration, rate limiting and the network perimeter each need their own analysis. A parser that cannot be crashed still hands out another user's data if the access check is missing.
  • The demonstrations are single-process and single-machine. The memory figure (1.4 GB) and the timings come from one Apple M1 Max in a release build. The ratio matters, not the number; measure your own limits.
  • Constant-time comparison was not timed. The guide shows the API and explains the reasoning, and does not claim to have measured a leak.
  • Unsafe code and FFI are out of scope. Everything above assumes safe Rust. Once a crate uses unsafe, the compiler's guarantees stop at that block and you need Miri, sanitizers and fuzzing, none of which is run here.
  • A clean advisory scan is not a clean bill of health. The database lists known problems. A malicious crate that nobody has reported yet will pass, so reduce the number of dependencies as well as scanning them.

The measurements are the argument: the naive path join served a file outside the root, a 50 MB JSON array pushed one process to 1.4 GB, a peer-chosen length panicked the slice, a 32-bit product wrapped to 65,536, and a derived Debug printed an API key. None of those is a memory-safety bug, and Rust did not stop any of them. Each has a short, testable fix, and a threat model is the list that tells you to write it.

Also inRust
Taggedsecurity
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.