Introduction
Imagine you've built a file transfer feature. It works great in testing — files come in, get parsed, and everything runs smoothly. But what happens when someone sends a JSON file with 10 million entries? Or a deeply nested JSON structure hundreds of levels deep? Or a file that's several gigabytes in size?
If your code doesn't answer those questions before it starts processing, you may be sitting on a Denial-of-Service (DoS) time bomb.
This post breaks down a real medium-severity vulnerability discovered in src/transfer/receiver.rs — a file import handler that lacked resource limits and input validation. We'll explore how it could be exploited, what the fix looks like, and how you can prevent similar issues in your own code.
The Vulnerability Explained
What Went Wrong
The file import functionality in receiver.rs had three interconnected problems:
- No file size limits — Files were loaded entirely into memory without any size check. A 4 GB file? Into RAM it goes.
- No JSON depth limits — JSON was parsed without restricting nesting depth. A deeply recursive structure can cause a parser to blow its stack or consume exponential memory.
- No entry count limits — Files with millions of top-level entries were processed without any cap.
In addition, the receiver began processing file data immediately upon connection, without verifying the peer's identity. Any connecting party was treated as a trusted peer — no authentication handshake, no public key verification, no pre-shared key mechanism.
The Technical Picture
Here's a simplified mental model of what the vulnerable code flow looked like:
// VULNERABLE: No size check, no depth limit, no authentication
async fn receive_file_from_request(
request: IncomingRequest,
sender_pk: &[u8],
) -> Result<(), FenvoyError> {
let filename = sanitize_filename(&request.filename);
// ❌ No check: Is sender_pk valid or non-empty?
// ❌ No check: How large is this file?
// ❌ No check: Is this JSON nested 10,000 levels deep?
let raw_bytes = read_entire_file(&request).await?; // Loads everything into memory
let parsed: Value = serde_json::from_slice(&raw_bytes)?; // Parses without depth guard
process_entries(parsed).await?;
Ok(())
}
The function trusts everything it receives. It doesn't ask who is sending, how big the payload is, or how complex the structure is.
How Could It Be Exploited?
An attacker doesn't need sophisticated tools to exploit this. Here are three concrete attack scenarios:
Scenario 1: The Giant File Attack
# Attacker generates a 10 GB file of valid-looking JSON
python3 -c "
import json, sys
data = {'entries': ['x' * 1000] * 10_000_000}
json.dump(data, sys.stdout)
" > evil_import.json
# Sends it to the receiver endpoint
curl -X POST https://target/transfer/receive \
--data-binary @evil_import.json
The server attempts to load 10 GB into RAM. If it doesn't OOM-kill first, it will grind to a halt.
Scenario 2: The Deep Nesting Attack (JSON Bomb)
# Generate deeply nested JSON — 100,000 levels deep
nested = "x"
for _ in range(100_000):
nested = f'{{"a": {nested}}}'
with open("deep_nest.json", "w") as f:
f.write(nested)
Many JSON parsers recurse into nested structures. At sufficient depth, this causes stack overflow or exponential memory allocation — crashing the process or making it unresponsive.
Scenario 3: The Million Entries Attack
# A flat JSON array with 50 million tiny entries
import json
data = list(range(50_000_000))
with open("million_entries.json", "w") as f:
json.dump(data, f)
Even if each entry is small, allocating and iterating 50 million objects can consume gigabytes of heap memory and saturate the CPU for minutes.
Real-World Impact
| Attack Vector | Potential Impact |
|---|---|
| Oversized file upload | Memory exhaustion, OOM crash |
| Deeply nested JSON | Stack overflow, process crash |
| Millions of entries | CPU saturation, heap exhaustion |
| Unauthenticated sender | Any peer can trigger the above |
Because there was no authentication check, any of these attacks could be launched by an anonymous, unauthenticated attacker — no credentials required. This elevates the practical risk significantly.
The Fix
What Changed
The fix introduces an explicit peer identity validation guard at the entry point of receive_file_from_request, placed immediately after filename sanitization and before any disk or network operations begin.
// FIXED: Validate sender identity before processing anything
async fn receive_file_from_request(
request: IncomingRequest,
sender_pk: &[u8],
) -> Result<(), FenvoyError> {
let filename = sanitize_filename(&request.filename);
// ✅ NEW: Reject requests from unidentified peers immediately
if sender_pk.is_empty() {
return Err(FenvoyError::AuthenticationFailed);
}
// Further processing only happens for authenticated peers
let raw_bytes = read_entire_file(&request).await?;
let parsed: Value = serde_json::from_slice(&raw_bytes)?;
process_entries(parsed).await?;
Ok(())
}
Why This Placement Matters
The guard is inserted after filename sanitization but before any I/O operations. This is the optimal position for a few reasons:
- Fail fast: The function returns immediately with zero file data read from the network.
- No resource allocation: No memory is allocated for file contents before the check passes.
- Defense in depth: Even if a bug elsewhere allows a connection to reach this function, the identity check acts as a final gate.
The Before/After Comparison
Before the fix, the trust model looked like this:
[Any Peer] → Connect → Send File → Parse → Process
↑
No gates here
After the fix, the flow is:
[Any Peer] → Connect → Identity Check → ❌ Reject (if empty PK)
→ ✅ Send File → Parse → Process
What This Fix Does Not Yet Cover
It's worth being transparent: the authentication guard addresses the who problem (unauthenticated senders), but the original vulnerability description also called out resource limits and JSON depth constraints. A complete remediation should also include:
// Recommended additional guards (not yet in this PR)
const MAX_FILE_SIZE_BYTES: usize = 50 * 1024 * 1024; // 50 MB
const MAX_JSON_DEPTH: usize = 32;
const MAX_ENTRY_COUNT: usize = 100_000;
// Check file size before reading
if request.content_length > MAX_FILE_SIZE_BYTES {
return Err(FenvoyError::FileTooLarge);
}
// Use a depth-limited JSON parser
let parsed = serde_json::from_slice_with_depth_limit(
&raw_bytes,
MAX_JSON_DEPTH
)?;
The authentication fix is a critical first step — it ensures only trusted peers can even attempt a transfer — but resource limit enforcement should follow as a next priority.
Conclusion
This vulnerability is a textbook example of how missing input validation at trust boundaries can turn a useful feature into an attack surface. The file receiver trusted everything — who was sending, how large the payload was, and how complex the structure was — without questioning any of it.
The fix is a strong first step: by rejecting unauthenticated peers at the earliest possible point, we prevent anonymous attackers from ever triggering the resource-intensive code paths. But as we've discussed, a complete defense-in-depth strategy requires resource limits, depth constraints, and rate limiting working together.
Key takeaways for developers:
- 🔐 Authenticate before you process — never touch untrusted data before verifying who sent it.
- 📏 Set explicit limits — file sizes, JSON depth, collection counts, and timeouts should all have hard caps.
- 🚫 Fail fast and loudly — return errors early, before allocating memory or opening files.
- 🧪 Test your limits — write security tests that deliberately try to exceed your thresholds.
- 📖 Know your CWEs — CWE-400 (resource exhaustion) and CWE-306 (missing authentication) are among the most common and preventable vulnerability classes.
Security isn't about perfection — it's about raising the cost of attack at every layer. Each guard you add forces an attacker to work harder. Keep stacking them.
Found a similar issue in your codebase? Consider auditing all file ingestion and data import paths for missing size limits, depth checks, and authentication guards. A quick grep for read_to_end, from_slice, or load_file in your network-facing code is a good place to start.