
Preventing HTTP Response Splitting in Rust
What HTTP response splitting looks like
The core problem is header injection through newline characters. Consider a feature that sets a download filename from a query parameter or returns a redirect URL from user input. If the value contains CRLF, the server may emit malformed headers.
A malicious value like this:
report.pdf\r\nSet-Cookie: session=attackercan turn one header into two. Depending on the framework, proxy chain, and client behavior, the result may include cache poisoning, cookie injection, or response body corruption.
Common attack surfaces
Response splitting usually appears in code that:
- Builds
Locationheaders for redirects - Sets
Content-Dispositionfilenames - Echoes user input in custom headers
- Constructs CORS-related headers dynamically
- Copies values from request parameters, form fields, or database records into response metadata
The risk is highest when developers assume “it’s just a header value” and skip validation because the value is not part of the HTML body.
Why Rust still needs explicit header validation
Rust prevents memory corruption, but HTTP header safety is a semantic problem, not a memory safety problem. A String can still contain \r and \n. If you pass that string into a response builder without checking, the runtime cannot infer your intent.
The good news is that Rust web frameworks and HTTP libraries often provide typed header APIs that reject invalid values. The best defense is to use those APIs consistently and avoid manual string concatenation.
Safe vs. unsafe header construction
The table below summarizes common patterns.
| Pattern | Risk level | Notes |
|---|---|---|
| Concatenating raw strings into header lines | High | Easy to inject CRLF or invalid syntax |
| Using typed header setters with validation | Low | Preferred approach |
Allowing arbitrary user input in Location or Content-Disposition | Medium to high | Safe only with strict encoding and validation |
| Whitelisting known values before mapping to headers | Low | Best for enums and fixed options |
A useful rule: if a header value can be influenced by a user, treat it as untrusted data until it has been validated or encoded for the exact header context.
Use framework and library header types
Most Rust HTTP stacks expose header abstractions that validate values before sending them. For example, http::HeaderValue rejects newline characters and other invalid bytes. That means you should prefer typed header insertion over raw string formatting.
Example: safe redirect handling with http and axum
use axum::{
http::{header, HeaderValue, StatusCode, Uri},
response::{IntoResponse, Response},
};
fn safe_redirect(target: &str) -> Result<Response, StatusCode> {
// Parse as a URI first. This rejects many malformed inputs.
let uri: Uri = target.parse().map_err(|_| StatusCode::BAD_REQUEST)?;
// Restrict redirects to relative paths to avoid open redirect issues.
if uri.scheme().is_some() || uri.authority().is_some() {
return Err(StatusCode::BAD_REQUEST);
}
let mut response = Response::new(axum::body::Body::empty());
*response.status_mut() = StatusCode::FOUND;
// HeaderValue validates that the value contains no CRLF or invalid bytes.
let location = HeaderValue::from_str(uri.to_string().as_str())
.map_err(|_| StatusCode::BAD_REQUEST)?;
response.headers_mut().insert(header::LOCATION, location);
Ok(response)
}This example addresses two concerns at once:
- It prevents response splitting by rejecting invalid header bytes.
- It reduces open redirect risk by allowing only relative targets.
Even if your framework offers a redirect helper, validate the destination before using it.
Validate by context, not just by character filtering
A common mistake is to “sanitize” header values by stripping \r and \n. That may stop the most obvious injection vector, but it is usually not enough. Different headers have different syntax rules, and some values require encoding rather than removal.
Better validation strategies
- Allowlist expected values: Use enums for known header options such as
same-site,no-store, or a fixed set of content types. - Parse into structured types: Use
Uri,HeaderValue, or dedicated parsing crates instead of raw strings. - Encode for the specific header: For filenames, use proper
Content-Dispositionencoding rather than manual concatenation. - Reject ambiguous input: If a value cannot be represented safely, return
400 Bad Requestor fall back to a safe default.
Example: safe custom header from a user profile field
Suppose you want to expose a user’s display name in a custom header for internal tooling. A safe approach is to validate length and character set before insertion.
use http::{header::HeaderName, HeaderValue, Response, StatusCode};
fn build_response(display_name: &str) -> Result<Response<String>, StatusCode> {
if display_name.is_empty() || display_name.len() > 64 {
return Err(StatusCode::BAD_REQUEST);
}
if !display_name.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ' || c == '-' || c == '_') {
return Err(StatusCode::BAD_REQUEST);
}
let mut response = Response::new("ok".to_string());
let name = HeaderName::from_static("x-display-name");
let value = HeaderValue::from_str(display_name).map_err(|_| StatusCode::BAD_REQUEST)?;
response.headers_mut().insert(name, value);
Ok(response)
}This code is intentionally strict. For security-sensitive headers, strict allowlists are usually safer than permissive filtering.
Handle redirects carefully
Redirects are one of the most common places where response splitting and related header issues appear. A redirect target often comes from a query parameter like next, return_to, or redirect_uri.
Risks to avoid
- CRLF injection into
Location - Open redirects to attacker-controlled domains
- Scheme confusion such as
javascript:ordata: - Path confusion caused by malformed relative URLs
Recommended pattern
- Parse the input as a URI.
- Require a relative path unless external redirects are explicitly needed.
- Normalize and validate the path.
- Insert the value using a typed header API.
If you must support external redirects, maintain an allowlist of trusted hosts and exact schemes. Do not rely on substring checks such as ends_with("example.com"), which can be bypassed with crafted domains.
Safely building Content-Disposition
File download endpoints often set a Content-Disposition header using a filename derived from user input or stored metadata. This header is especially sensitive because filenames may contain quotes, semicolons, spaces, and non-ASCII characters.
Unsafe approach
let header = format!("attachment; filename=\"{}\"", filename);This is unsafe because filename can break out of the quoted string or include CRLF.
Safer approach
Use a dedicated builder or encode the filename according to RFC 5987 / RFC 6266 rules when supported by your framework or library. If no helper exists, validate the filename strictly and reject unsafe characters.
A practical policy is:
- Accept only a limited character set for simple filenames
- Replace unsupported characters with
_only if that does not create ambiguity - Prefer server-generated filenames when possible
For example, invoice-2024-06.pdf is a much safer header value than a raw user-supplied title.
Defensive design patterns for Rust services
The best protection is to make unsafe header construction difficult to write in the first place.
1. Wrap header values in domain types
Instead of passing raw strings around, define a type that validates on construction.
use http::HeaderValue;
pub struct SafeHeaderValue(HeaderValue);
impl SafeHeaderValue {
pub fn new(input: &str) -> Result<Self, &'static str> {
if input.contains('\r') || input.contains('\n') {
return Err("invalid header value");
}
let value = HeaderValue::from_str(input).map_err(|_| "invalid header value")?;
Ok(Self(value))
}
pub fn as_header_value(&self) -> &HeaderValue {
&self.0
}
}This pattern centralizes validation and makes unsafe insertion harder to repeat across the codebase.
2. Keep header logic close to the response layer
Do not let arbitrary application code assemble header strings. Instead, have a small response-building module that owns all header insertion logic. That makes it easier to audit and test.
3. Prefer fixed mappings
If a user choice maps to a header value, use an enum:
enum CachePolicy {
NoStore,
Private,
Public,
}Then convert the enum to a known-safe header string. This avoids accepting arbitrary text where only a few values are needed.
Testing for response splitting vulnerabilities
Security bugs in headers are easy to miss in manual testing because most normal inputs work fine. Add tests that explicitly cover malicious payloads.
Useful test cases
- Values containing
\r - Values containing
\n - Values containing
\r\n - Very long values
- Unicode and non-ASCII input
- Inputs that look like header syntax, such as
foo: bar
Example test
#[test]
fn rejects_crlf_in_header_value() {
let bad = "report.pdf\r\nSet-Cookie: session=evil";
let result = http::HeaderValue::from_str(bad);
assert!(result.is_err());
}For integration tests, send crafted requests through your full stack and inspect the raw HTTP response. This is especially important if your service sits behind a reverse proxy, because intermediary behavior can differ from local unit tests.
Operational best practices
Secure code is only part of the solution. Deployment and framework configuration also matter.
- Use current framework versions: Header parsing and response helpers improve over time.
- Log rejected inputs carefully: Record enough context for debugging, but do not reflect malicious header payloads back into logs without escaping.
- Review proxy behavior: Some reverse proxies normalize or rewrite headers. Test the full request path.
- Set strict response policies: If your application never needs dynamic custom headers, avoid them entirely.
- Audit helper functions: Small utility functions that “just build headers” are common sources of bugs.
A good code review question is: “Can any untrusted value reach this header insertion point without structured validation?”
Practical checklist
Before shipping a feature that sets response headers from application data, verify the following:
- The value is parsed or validated for the exact header context
- CRLF characters are rejected, not merely stripped
- Redirect targets are restricted to safe destinations
HeaderValueor equivalent typed APIs are usedContent-Dispositionfilenames are encoded or strictly allowlisted- Tests cover malicious inputs and edge cases
- Header construction is centralized and easy to audit
If you can answer “yes” to all of these, your application is much less likely to be vulnerable to response splitting.
