Denial of Service Through Rust Parsers and Resource Limits
A parser that accepts any input can be made to use unbounded memory, time or stack. Set limits on size, depth and time, and know which crates already have them.

A request with a 3 MB body, 100,000 levels of nested brackets or a gzip stream that expands to 4 GB is perfectly legal bytes. Memory safety does not help, because the problem is not corruption: it is that the program did exactly what you wrote with input much bigger than you expected. This post lists six resource limits and how to apply each in a Rust service. It is part of the input risks in security for Rust developers.
Rust 1.90, Axum 0.8.
1. Size
Reading a body into memory without a cap lets one client allocate arbitrarily. Axum's Json, Form and Bytes extractors apply a 2 MB default. Set it explicitly where you need something else:
use axum::{extract::DefaultBodyLimit, routing::post, Router};
fn app() -> Router {
Router::new()
.route("/upload", post(|body: axum::body::Bytes| async move { body.len().to_string() }))
.layer(DefaultBodyLimit::max(1024 * 1024))
}For streamed bodies, wrap with tower_http::limit::RequestBodyLimitLayer, which enforces the limit as bytes arrive and rejects requests that declare more in Content-Length. Also cap the number of headers and their sizes in the server config.
2. Depth
fn depth(v: &serde_json::Value) -> usize {
match v {
serde_json::Value::Array(a) => 1 + a.iter().map(depth).max().unwrap_or(0),
serde_json::Value::Object(o) => 1 + o.values().map(depth).max().unwrap_or(0),
_ => 0,
}
}This function is itself recursive, and calling it on an adversarial value would overflow the stack, but serde_json refuses to parse beyond depth 128 by default ("recursion limit exceeded"), so the value cannot get that deep. Your own recursive-descent parsers need a depth counter; stack overflows abort the process and cannot be caught (debugging a stack overflow).
3. Expansion
Decompression, XML entity expansion and archive extraction can multiply input size. Limit the output, not just the input:
use std::io::Read;
use flate2::read::GzDecoder;
fn gunzip_limited(data: &[u8], max: u64) -> std::io::Result<Vec<u8>> {
let mut out = Vec::new();
GzDecoder::new(data).take(max + 1).read_to_end(&mut out)?;
if out.len() as u64 > max {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "decompressed data too large"));
}
Ok(out)
}take(max + 1) stops reading after one byte over the limit, so memory use is bounded by max.
4. Algorithmic cost
Hash tables keyed by attacker-controlled strings are vulnerable to collision floods if the hasher is predictable. The standard HashMap is safe by default; see faster hashers before changing it. The regex crate guarantees linear-time matching, so pattern backtracking is not a risk, but a huge pattern can use memory (size_limit). Sorting or quadratic loops over input lists need a cap on list length.
5. Time
use std::time::Duration;
use tower_http::timeout::TimeoutLayer;A timeout layer ends slow requests, which counters slow-body ("slowloris") attacks. Apply timeouts at every outbound call too (timeouts in Tokio).
6. Concurrency
Without a limit on in-flight work, N clients cause N simultaneous expensive operations. A tokio::sync::Semaphore or Tower's ConcurrencyLimitLayer bounds it, and a bounded queue pushes back instead of buffering forever.
A budget per request
Decide for each endpoint: max body, max depth, max items, max time, max concurrency. Write them in the handler's documentation. Fuzz the parser with cargo fuzz to find inputs that blow a budget.
Limits
Limits protect one process, and a determined distributed attack needs rate limiting and infrastructure defenses. The checks above do not replace authentication in front of expensive endpoints. The tower-http crate documents its layers at docs.rs/tower-http.


