
Preventing Unsafe HTTP Header Handling in Rust: Building Requests That Resist Injection and Smuggling
Why header handling becomes a security problem
HTTP headers are not free-form text. A header name must follow token rules, and a header value must not contain control characters such as carriage return or line feed. If an attacker can inject \r\n, they may be able to terminate one header and start another, or even manipulate the request body boundary in systems that serialize headers incorrectly.
Common failure modes include:
- building raw HTTP messages with
format! - copying user input into header names
- forwarding untrusted values into proxy or upstream request headers
- using custom serialization code that does not reject control characters
- trusting values from query parameters, cookies, or form fields without normalization
Rust’s ecosystem helps here, but only if you use the typed APIs instead of assembling protocol text manually.
Prefer typed header APIs over raw string construction
The safest pattern is to use the http::HeaderMap and typed header types rather than concatenating strings. These APIs enforce syntax rules and reject invalid names or values.
Safe example with HeaderMap
use http::{HeaderMap, HeaderName, HeaderValue};
fn build_headers(user_agent: &str, request_id: &str) -> Result<HeaderMap, http::header::InvalidHeaderValue> {
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("user-agent"),
HeaderValue::from_str(user_agent)?,
);
headers.insert(
HeaderName::from_static("x-request-id"),
HeaderValue::from_str(request_id)?,
);
Ok(headers)
}This code is safe because HeaderValue::from_str rejects invalid bytes, including control characters. If an attacker submits evil\r\nx-admin: true, the conversion fails instead of silently producing a malformed request.
Unsafe pattern to avoid
fn build_raw_headers(user_agent: &str) -> String {
format!("User-Agent: {}\r\n", user_agent)
}This is dangerous because it treats untrusted input as protocol text. Even if you later “sanitize” it, the burden is easy to get wrong and hard to audit.
Validate both header names and values
Header names are more restrictive than values. If your application allows users to choose custom header names, validate them against a known allowlist whenever possible.
Use an allowlist for dynamic headers
use http::{HeaderMap, HeaderName, HeaderValue};
fn insert_custom_header(
headers: &mut HeaderMap,
name: &str,
value: &str,
) -> Result<(), String> {
let allowed = ["x-client-version", "x-trace-id", "x-feature-flag"];
if !allowed.contains(&name) {
return Err("unsupported header name".into());
}
let header_name = HeaderName::from_bytes(name.as_bytes())
.map_err(|_| "invalid header name")?;
let header_value = HeaderValue::from_str(value)
.map_err(|_| "invalid header value")?;
headers.insert(header_name, header_value);
Ok(())
}This pattern gives you two layers of protection:
- business-level restriction through an allowlist
- protocol-level validation through typed parsing
That combination is much safer than accepting arbitrary header names from users or configuration.
Be careful when forwarding headers from incoming requests
A common server-side pattern is to forward selected headers from an inbound request to an upstream service. This is useful for correlation IDs, locale preferences, or authorization tokens, but it can become dangerous if you forward too much.
Forward only explicitly allowed headers
| Header type | Safe to forward? | Notes |
|---|---|---|
x-request-id | Yes | Useful for tracing if validated |
accept-language | Yes | Usually harmless, but normalize if needed |
authorization | Sometimes | Forward only when the upstream is trusted and intended to receive it |
host | No | Can alter routing and virtual host behavior |
content-length | No | Can create request smuggling issues |
transfer-encoding | No | Should be controlled by the HTTP client |
connection | No | Hop-by-hop header; do not propagate |
Hop-by-hop headers are especially important. They are meant for a single transport hop and should not be forwarded by proxies or application code. Forwarding them can create ambiguous request semantics or break intermediaries.
Example: filtering headers before proxying
use http::{HeaderMap, HeaderName};
fn copy_safe_headers(inbound: &HeaderMap) -> HeaderMap {
let mut outbound = HeaderMap::new();
for name in ["accept-language", "x-request-id"] {
let header_name = HeaderName::from_static(name);
if let Some(value) = inbound.get(&header_name) {
outbound.insert(header_name, value.clone());
}
}
outbound
}This approach is intentionally narrow. If you need more headers, add them one by one and document why they are safe.
Avoid raw HTTP serialization unless you fully control the protocol
Some applications build HTTP requests manually for specialized integrations, embedded systems, or testing tools. If you do this, you must treat the output as a protocol encoder, not as string formatting.
Risks of manual serialization
- CRLF injection through values
- duplicated headers with conflicting semantics
- malformed line endings
- incorrect content length handling
- accidental inclusion of user-controlled header names
If you cannot avoid manual serialization, validate every field and reject any value containing control characters. In most cases, using reqwest or hyper is the better choice.
Use reqwest and hyper to delegate protocol correctness
Higher-level clients already know how to encode headers safely. They also handle details such as HTTP/2 framing, connection reuse, and content length management.
Example with reqwest
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
async fn send_request(token: &str) -> Result<(), reqwest::Error> {
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("x-api-token"),
HeaderValue::from_str(token).map_err(|_| {
reqwest::Error::new(
reqwest::StatusCode::BAD_REQUEST,
"invalid token header value",
)
})?,
);
let client = reqwest::Client::new();
let response = client
.get("https://api.example.com/data")
.headers(headers)
.send()
.await?;
println!("status: {}", response.status());
Ok(())
}In real code, prefer returning your own application error type instead of trying to construct a reqwest::Error directly. The important point is that header validation happens before the request is sent.
Normalize and constrain user-controlled header values
Even when a value is syntactically valid, it may still be unsafe from a business perspective. For example, a locale header could be used to trigger unexpected backend behavior if you accept arbitrary strings.
Good practices for values
- trim leading and trailing whitespace if your application semantics allow it
- restrict values to a known set when possible
- cap length to prevent resource abuse
- reject embedded control characters
- avoid passing raw user input into security-sensitive headers
A good rule is: if the header affects routing, authentication, caching, or authorization, treat it as security-sensitive and validate it strictly.
Protect proxies and middleware from header confusion
If your Rust service sits behind a reverse proxy or acts as one, header handling becomes even more important. Different components may interpret duplicate headers differently. Attackers can exploit inconsistencies between layers.
Defensive measures
- reject or normalize duplicate security-sensitive headers
- do not let clients set
Host,Content-Length, orTransfer-Encoding - preserve a clear boundary between inbound and outbound header sets
- use a single source of truth for request metadata
- log header names only when necessary, and avoid logging values that may contain secrets
If your application uses middleware, ensure each layer knows whether it is reading client headers, internal headers, or upstream headers. Mixing those responsibilities is a common source of bugs.
Test for malformed and malicious inputs
Security-sensitive code should be tested with invalid header data, not just happy-path examples. Include cases with control characters, oversized values, duplicate fields, and unsupported names.
Example tests
#[cfg(test)]
mod tests {
use http::header::HeaderValue;
#[test]
fn rejects_crlf_in_header_value() {
assert!(HeaderValue::from_str("abc\r\nx-test: injected").is_err());
}
#[test]
fn accepts_simple_value() {
assert!(HeaderValue::from_str("trace-123").is_ok());
}
}You can also add property-based tests to ensure that arbitrary input never produces a serialized header containing CR or LF. That is especially useful if you have custom encoding or proxy logic.
Practical checklist for safe header handling
| Goal | Recommended approach |
|---|---|
| Build headers | Use HeaderMap, HeaderName, and HeaderValue |
| Accept user-supplied names | Prefer an allowlist |
| Accept user-supplied values | Validate syntax and business rules |
| Forward headers | Copy only explicitly allowed headers |
| Prevent injection | Reject control characters and avoid raw string concatenation |
| Handle proxies | Do not forward hop-by-hop headers |
| Test security | Include malformed input and duplicate-header cases |
A secure design pattern for real applications
A robust Rust service usually follows this flow:
- receive inbound request data
- extract only the headers you need
- validate names and values separately
- store normalized data in typed structures
- build outbound requests using typed HTTP APIs
- forward only a minimal, documented header set
This pattern keeps protocol concerns isolated from business logic. It also makes reviews easier because unsafe behavior is concentrated in a few small functions.
Conclusion
Unsafe HTTP header handling is a subtle but serious security risk. The main defense is to stop treating headers as plain strings and instead use typed APIs that enforce protocol syntax. Combine that with allowlists, strict validation, and minimal forwarding, and your Rust code will be much harder to abuse through header injection or smuggling.
If you remember one rule, make it this: never let untrusted input become raw HTTP text.
