Header Injection
The pattern repeats
SQL injection puts code into SQL. Command injection puts code into shell commands. Header injection puts code into HTTP headers. Same principle, different interpreter.
The vulnerable code
Imagine a route that sets a custom header based on user input — perhaps a language preference from a query parameter:
route.get("/notes", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const lang = new URL(c.request.url).searchParams.get("lang") ?? "en";
const notes = db.prepare("SELECT * FROM notes WHERE user_id = ?").all(user.id);
return new Response(JSON.stringify(notes), {
headers: {
"content-type": "application/json",
"content-language": lang, // DELIBERATELY VULNERABLE
},
});
},
}); The lang parameter is inserted directly into a response header.
The attack: CRLF injection
HTTP headers are separated by \r\n (carriage return + line feed, or CRLF). If the attacker can inject \r\n into a header value, they can add arbitrary headers to the response.
The attacker sets lang to: en\r\nSet-Cookie: session=attacker-session
The response becomes:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Language: en
Set-Cookie: session=attacker-session
[...] The attacker just set a cookie on the victim’s browser. This can be used for session fixation (forcing the victim to use a session the attacker controls).
[!NOTE] Modern Node.js runtimes and the Web
ResponseAPI reject headers containing\ror\n, which prevents the most basic form of this attack. But you should still validate header values because (1) not all runtimes protect you, (2) the protection may not cover all edge cases, and (3) defense in depth means not relying on a single layer.
The fix: validate header values
Never use raw user input as a header value. Validate it against an allowlist or strip control characters:
const ALLOWED_LANGUAGES = new Set(["en", "es", "fr", "de", "ja", "zh"]);
route.get("/notes", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const rawLang = new URL(c.request.url).searchParams.get("lang") ?? "en";
const lang = ALLOWED_LANGUAGES.has(rawLang) ? rawLang : "en";
const notes = db.prepare("SELECT * FROM notes WHERE user_id = ?").all(user.id);
return new Response(JSON.stringify(notes), {
headers: {
"content-type": "application/json",
"content-language": lang, // SAFE: validated against allowlist
},
});
},
}); The allowlist approach is the safest: the value must be one of the predefined options. Anything else falls back to the default.
If an allowlist is not practical (the value is too dynamic), strip control characters:
function sanitizeHeaderValue(value: string): string {
return value.replace(/[\r\n\x00]/g, "");
} This removes carriage returns, line feeds, and null bytes — the characters used for header injection.
Exercises
Exercise 1: Add the language header route to your app. Try the CRLF injection with curl. Does your runtime block it? (Modern Node.js should throw an error.)
Exercise 2: Apply the allowlist fix. Try setting lang=xx and verify it falls back to en.
What character sequence enables header injection?