
Preventing Deserialization Vulnerabilities in Rust: Safely Handling Untrusted Data
Why deserialization is a security boundary
Deserialization is not just parsing. It is the moment your application decides that a blob of data is trustworthy enough to become a structured value. If that decision is too permissive, attackers can exploit it in several ways:
- Resource exhaustion: oversized arrays, deeply nested structures, or huge strings can consume memory and CPU.
- Type confusion: accepting fields you did not intend to support can create logic bugs.
- Privilege escalation: deserialized values may drive authorization, routing, or filesystem access.
- Unsafe object construction: custom deserializers can trigger side effects or bypass invariants.
Rust’s type system helps, but it does not automatically make deserialization secure. The key is to design narrow schemas and validate them at the boundary.
Prefer explicit schemas over flexible input
The safest deserialization strategy is to accept only the fields and shapes your application actually needs. Avoid generic “catch-all” structures unless you have a strong reason.
Good practice: define a minimal request type
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct LoginRequest {
username: String,
password: String,
}This is better than deserializing into a broad map and interpreting keys manually. The type itself documents the contract and limits what the parser will accept.
Reject unknown fields
By default, serde ignores extra fields in many formats. That can be dangerous if attackers smuggle unexpected data into requests, especially when later code assumes the payload was fully validated.
Use deny_unknown_fields for security-sensitive inputs:
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LoginRequest {
username: String,
password: String,
}This forces clients to send only the fields you expect. It is especially useful for authentication, configuration, and API endpoints where strictness is preferable to flexibility.
Constrain size and depth before deserializing
A common mistake is assuming the parser will “just fail” on maliciously large input. In practice, many formats can be used to create expensive allocations or recursive structures.
Apply transport-level limits first
Before deserializing, cap the amount of data you read from the source:
- HTTP request bodies: enforce a maximum body size.
- Files: reject unexpectedly large files.
- Message queues: validate message size before parsing.
- Streams: read only a bounded prefix when possible.
For example, with a byte buffer already loaded in memory, check length before parsing:
const MAX_BODY: usize = 16 * 1024;
fn parse_login(body: &[u8]) -> Result<LoginRequest, serde_json::Error> {
if body.len() > MAX_BODY {
return Err(serde_json::Error::custom("payload too large"));
}
serde_json::from_slice(body)
}Limit nesting and recursion
Deeply nested JSON, YAML, or similar formats can trigger stack growth or expensive traversal. If your format or parser supports it, configure a maximum depth. If not, consider switching to a simpler format or validating structure after parsing into a bounded representation.
Use bounded collection types
If a field should contain at most a small number of items, do not deserialize into an unbounded Vec unless you validate length immediately afterward. Prefer a fixed-size array when the count is known, or enforce a maximum after parsing:
#[derive(Debug, Deserialize)]
struct BatchRequest {
items: Vec<String>,
}
fn validate_batch(req: &BatchRequest) -> Result<(), &'static str> {
if req.items.len() > 100 {
return Err("too many items");
}
Ok(())
}Validate invariants after deserialization
Deserialization answers “can this data be represented as a Rust value?” It does not answer “is this value safe for my application?”
Separate parsing from validation
A secure pattern is:
- Deserialize into a narrow input type.
- Validate semantic constraints.
- Convert into a trusted domain type.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct UserInput {
email: String,
age: u8,
}
#[derive(Debug)]
struct UserProfile {
email: String,
age: u8,
}
impl TryFrom<UserInput> for UserProfile {
type Error = &'static str;
fn try_from(input: UserInput) -> Result<Self, Self::Error> {
if !input.email.contains('@') {
return Err("invalid email");
}
if input.age < 13 {
return Err("age below minimum");
}
Ok(Self {
email: input.email,
age: input.age,
})
}
}This pattern keeps untrusted data out of your core logic until it has passed explicit checks.
Validate format, range, and relationships
Do not stop at “non-empty.” Check the properties that matter to your application:
- string length
- numeric ranges
- allowed character sets
- cross-field consistency
- timestamps and expiration windows
- enum values and state transitions
For example, if a request contains start and end, ensure start <= end. If it contains a role, ensure the role is one of the allowed application roles, not just any string.
Be careful with custom deserializers
Custom Deserialize implementations can be powerful, but they are also easy to misuse. They often hide complex logic in a place developers expect to be simple and deterministic.
Use custom deserialization only for narrow transformations
Good examples include:
- parsing a string into a validated newtype
- converting a wire format into a domain-specific enum
- enforcing canonical formatting
Avoid performing I/O, making network calls, or consulting mutable global state during deserialization. That turns parsing into an execution step and can create surprising attack surfaces.
Prefer newtypes for validated values
A newtype makes the security boundary explicit:
use serde::Deserialize;
use std::convert::TryFrom;
#[derive(Debug)]
struct Port(u16);
impl TryFrom<u16> for Port {
type Error = &'static str;
fn try_from(value: u16) -> Result<Self, Self::Error> {
if (1024..=65535).contains(&value) {
Ok(Port(value))
} else {
Err("port out of allowed range")
}
}
}
#[derive(Debug, Deserialize)]
struct ServiceConfigRaw {
port: u16,
}After deserialization, convert u16 into Port and reject invalid values early. This keeps invalid states out of the rest of the codebase.
Choose formats and libraries with security in mind
Different data formats have different risk profiles. The table below summarizes common tradeoffs.
| Format | Strengths | Security considerations |
|---|---|---|
| JSON | Simple, widely supported | Can still carry huge payloads or deeply nested structures |
| TOML | Good for configuration | Usually local-only, but still validate unknown fields and ranges |
| YAML | Human-friendly | More complex semantics; avoid for untrusted input unless necessary |
| CBOR / MessagePack | Compact binary formats | Enforce strict schema and size limits; binary data can hide large payloads |
For untrusted input, prefer formats with predictable parsing behavior and well-maintained libraries. Simpler is usually safer.
Avoid permissive “deserialize anything” patterns
Structures like HashMap<String, Value> are convenient for prototyping, but they defer validation and make it easy to forget security checks. If you must accept dynamic fields, isolate them in a dedicated substructure and validate the allowed keys explicitly.
Harden API endpoints that accept external payloads
Web services, workers, and RPC handlers are common deserialization boundaries. A secure endpoint should combine transport limits, strict schemas, and validation.
Example: secure JSON request handling
use serde::Deserialize;
const MAX_BODY: usize = 16 * 1024;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CreateProjectRequest {
name: String,
visibility: String,
}
fn validate(req: &CreateProjectRequest) -> Result<(), &'static str> {
if req.name.len() > 64 {
return Err("name too long");
}
match req.visibility.as_str() {
"private" | "internal" | "public" => Ok(()),
_ => Err("invalid visibility"),
}
}
fn handle_request(body: &[u8]) -> Result<(), String> {
if body.len() > MAX_BODY {
return Err("payload too large".into());
}
let req: CreateProjectRequest =
serde_json::from_slice(body).map_err(|_| "invalid JSON".to_string())?;
validate(&req).map_err(|e| e.to_string())?;
Ok(())
}This approach avoids several common mistakes:
- no unbounded body size
- no unknown fields
- no implicit acceptance of arbitrary strings
- no direct use of unvalidated data
Treat configuration files as untrusted too
It is tempting to trust local configuration files because they are “not user input.” In real systems, configuration often comes from deployment pipelines, mounted volumes, package managers, or shared environments. That makes it a security boundary as well.
Recommended practices for config deserialization
- Use strict schemas and reject unknown keys.
- Validate paths, URLs, and ports before use.
- Avoid defaulting to dangerous values.
- Keep secrets separate from general config when possible.
- Fail closed if a required field is missing or malformed.
If a config file controls authentication endpoints, file paths, or feature flags, a malformed value can become a security issue quickly.
Common mistakes to avoid
1. Deserializing directly into privileged domain types
If a type assumes invariants like “this user is already authorized” or “this path is safe,” do not deserialize directly into it from untrusted input. Use a raw input type first.
2. Ignoring unknown fields
Unknown fields can indicate client bugs, version mismatches, or malicious probing. In security-sensitive contexts, reject them.
3. Trusting enums without validation
An enum in Rust is safer than a string, but only if the deserializer cannot silently map unexpected values into a default or fallback branch. Make invalid values fail explicitly.
4. Letting large inputs reach the parser
Even a correct parser can be overwhelmed by a huge payload. Always enforce size limits before parsing.
5. Mixing parsing and side effects
Deserialization should not write files, open sockets, or trigger business actions. Keep it pure and deterministic.
A practical checklist
Before accepting any external payload, verify the following:
- The input size is bounded.
- The schema is explicit and minimal.
- Unknown fields are rejected where appropriate.
- Nested structures and collections have practical limits.
- Validation happens after deserialization and before use.
- Domain types are constructed only from validated data.
- Custom deserializers are small, pure, and well-reviewed.
If you apply these rules consistently, deserialization becomes a controlled boundary instead of a hidden source of risk.
