HTTP Header Injection Code Review Guide
Table of Contents
Introduction
HTTP Header Injection, also known as CRLF injection, is a web vulnerability that occurs when an application includes user-supplied input in HTTP response headers without proper validation. Attackers exploit this by injecting Carriage Return (\r) and Line Feed (\n) characters—collectively called CRLF—to manipulate the HTTP response structure.
Because HTTP uses CRLF sequences to delimit headers and separate headers from the response body, injecting these characters allows attackers to forge arbitrary headers, split responses, poison caches, set malicious cookies, or even inject HTML/JavaScript into the response body. This makes header injection a gateway to numerous downstream attacks.
HTTP Header Injection Attack Flow
value%0d%0aEvil: hdrSets header with inputInjected headersResponse splittingCache poisoningXSS via headersSession fixationOpen redirectCookie injectionWhy This Matters
Header injection is frequently overlooked because developers rarely think of HTTP headers as an attack surface. Yet any code that sets a response header using user-controlled data—redirect URLs, cookie values, content-disposition filenames—is a potential target.
Real-World Scenario
Consider a language-selection feature that stores the user's preferred locale in a cookie. The application reads a lang query parameter and reflects it directly into a Set-Cookie header:
Vulnerable Node.js Redirect
1const express = require('express');
2const app = express();
3
4app.get('/set-language', (req, res) => {
5 const lang = req.query.lang;
6 // DANGEROUS: User input placed directly into a header
7 res.setHeader('Set-Cookie', `lang=${lang}; Path=/`);
8 res.redirect('/');
9});An attacker could request:/set-language?lang=en%0d%0aSet-Cookie:%20admin=true
The %0d%0a is a URL-encoded CRLF. The resulting HTTP response would contain a second, attacker-controlled Set-Cookie header, giving them the ability to set arbitrary cookies in the victim's browser.
Resulting Malicious Response
1HTTP/1.1 302 Found
2Set-Cookie: lang=en
3Set-Cookie: admin=true; Path=/
4Location: /
5Content-Length: 0What characters enable the attacker to inject a second header?
Understanding CRLF Injection
The HTTP protocol uses specific character sequences to structure responses. A CRLF (\r\n, hex 0x0D 0x0A) terminates each header line. A double CRLF (\r\n\r\n) separates headers from the body. Injecting these sequences lets attackers break out of the intended header context.
HTTP Response Structure
| Component | Separator | Example |
|---|---|---|
| Status line | CRLF at end | HTTP/1.1 200 OK\r\n |
| Header line | CRLF at end | Content-Type: text/html\r\n |
| Headers → Body | Double CRLF | \r\n (blank line) |
| Body content | None required | <html>...</html> |
Common CRLF Encoding Variants
| Encoding | Representation | Decoded |
|---|---|---|
| URL encoding | %0d%0a | \r\n |
| Double URL encoding | %250d%250a | %0d%0a → \r\n |
| Unicode | %u000d%u000a | \r\n |
| Mixed | %0d%0A | \r\n |
| LF only (some servers) | %0a | \n |
| Null + LF | %00%0a | \0\n |
LF-Only Injection
Some HTTP servers and frameworks accept a bare Line Feed (\n) without a preceding Carriage Return as a valid header delimiter. Always check for both \r\n and standalone \n when reviewing code or testing for this vulnerability.
What does a double CRLF (\\r\\n\\r\\n) signify in an HTTP response?
Finding Header Sinks
Header sinks are any API calls that set HTTP response headers. During code review, identifying these sinks is the first step—any sink that accepts user-controlled data is a potential CRLF injection point.
Common Header Sinks by Language
| Language / Framework | Sink Function | Risk |
|---|---|---|
| Node.js / Express | res.setHeader(name, value) | High if value is user-controlled |
| Node.js / Express | res.header(name, value) | High if value is user-controlled |
| Node.js / Express | res.redirect(url) | High—Location header from input |
| Python / Django | response[header] = value | High if value is user-controlled |
| Python / Flask | response.headers[name] = value | High if value is user-controlled |
| PHP | header("Name: $value") | Critical—raw header string |
| Java / Servlet | response.setHeader(name, value) | High if value is user-controlled |
| Java / Servlet | response.addHeader(name, value) | High if value is user-controlled |
| Java / Servlet | response.sendRedirect(url) | High—Location header from input |
| Ruby / Rails | response.headers[name] = value | High if value is user-controlled |
| Go | w.Header().Set(name, value) | High if value is user-controlled |
Identifying Sinks Across Languages
1// --- Node.js / Express ---
2// DANGEROUS: User input flows into header value
3app.get('/download', (req, res) => {
4 const filename = req.query.file;
5 res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
6 res.send(fileData);
7});
8
9// DANGEROUS: Redirect URL from user input
10app.get('/redirect', (req, res) => {
11 const target = req.query.url;
12 res.redirect(target); // Sets Location header
13});
14
15// DANGEROUS: Custom header from user input
16app.get('/api/data', (req, res) => {
17 const requestId = req.headers['x-request-id'];
18 res.setHeader('X-Request-Id', requestId); // Reflects header
19 res.json({ data: '...' });
20});PHP Header Sinks
1<?php
2// DANGEROUS: Direct user input into header()
3$lang = $_GET['lang'];
4header("Content-Language: $lang");
5
6// DANGEROUS: Redirect with user-controlled URL
7$url = $_GET['redirect'];
8header("Location: $url");
9
10// DANGEROUS: Cookie with user-controlled value
11$theme = $_GET['theme'];
12setcookie('theme', $theme);
13
14// DANGEROUS: Content-Disposition with filename
15$file = $_GET['filename'];
16header("Content-Disposition: attachment; filename="$file"");Which of these is a header injection sink?
Identifying Input Sources
User input can reach header sinks from many entry points. During code review, trace all data that eventually gets placed into a response header:
- Query parameters:
?lang=en,?redirect=https://...— the most common source, often used for locale, redirect URLs, and download filenames - Request headers:
X-Forwarded-For,X-Request-Id,Referer— applications that reflect or log incoming headers are at risk - Path parameters:
/api/v1/:version,/file/:name— route parameters used in Content-Disposition or custom headers - POST body / form data: Form fields whose values are later set as cookies or redirected to
- Cookie values: Cookies read from the request and re-set in the response (e.g., session migration)
- Database content: User profile fields, stored preferences, or configuration values retrieved from the database and placed into headers
Example: Multiple Input Sources Leading to Headers
1from flask import Flask, request, redirect, make_response
2
3app = Flask(__name__)
4
5@app.route('/profile')
6def profile():
7 resp = make_response(render_template('profile.html'))
8
9 # SOURCE: Query parameter → Cookie header
10 theme = request.args.get('theme', 'light')
11 resp.set_cookie('theme', theme) # SINK
12
13 # SOURCE: Request header → Response header
14 req_id = request.headers.get('X-Request-Id', '')
15 resp.headers['X-Request-Id'] = req_id # SINK
16
17 # SOURCE: Database → Response header
18 user = get_user(session['uid'])
19 resp.headers['X-User-Lang'] = user.preferred_language # SINK
20
21 return resp
22
23@app.route('/download')
24def download():
25 # SOURCE: Query parameter → Content-Disposition header
26 filename = request.args.get('file')
27 resp = make_response(get_file_content(filename))
28 resp.headers['Content-Disposition'] = f'attachment; filename="{filename}"' # SINK
29 return respSecond-Order Injection
Data stored in the database can be a hidden source. If a user sets their display name to Alice\r\nSet-Cookie: admin=true and the application later places that name in a custom header, the injection triggers when the stored value is retrieved—not when it was originally submitted.
Tracing Data Flow
Tracing the path from user input to a header sink is essential. In many applications, input flows through validation, transformation, or storage layers before reaching a header-setting function. Weak or missing sanitization at any point can leave the application vulnerable.
Data Flow Example
1@Controller
2public class LanguageController {
3
4 // 1. SOURCE: User input via query parameter
5 @GetMapping("/set-lang")
6 public ResponseEntity<Void> setLanguage(
7 @RequestParam String lang,
8 HttpServletResponse response) {
9
10 // 2. PROCESSING: Weak validation
11 String sanitized = lang.trim().toLowerCase(); // Does NOT strip CRLF
12
13 // 3. SINK: User-controlled value in Set-Cookie header
14 response.addHeader("Set-Cookie",
15 "lang=" + sanitized + "; Path=/; HttpOnly");
16
17 // Also vulnerable: redirect with user input
18 return ResponseEntity.status(302)
19 .header("Location", "/home?lang=" + sanitized)
20 .build();
21 }
22}Framework Protections May Not Be Enough
Many modern frameworks (Express 4.x+, Django, Spring) now reject header values containing CRLF characters at the API level. However, relying solely on framework protection is risky: older versions may be vulnerable, custom HTTP libraries may not sanitize, and there may be encoding bypass vectors. Always validate input explicitly.
Tracing Through Middleware
1// Step 1: Input received
2app.get('/api/export', (req, res) => {
3 const format = req.query.format; // SOURCE: "csv%0d%0aX-Evil: injected"
4 generateExport(format, res);
5});
6
7// Step 2: Passed through helper
8function generateExport(format, res) {
9 // No CRLF check here!
10 const filename = `export.${format}`;
11 setDownloadHeaders(filename, res);
12 res.send(exportData);
13}
14
15// Step 3: Reaches header sink
16function setDownloadHeaders(filename, res) {
17 // SINK: Tainted input in Content-Disposition
18 res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
19 res.setHeader('Content-Type', 'application/octet-stream');
20}An application calls trim() and toLowerCase() on user input before placing it in a header. Is this safe?
Prevention Techniques
Preventing header injection requires rejecting or stripping CRLF characters before any user-controlled data reaches a header-setting API. The best approach combines input validation with framework-level protections.
Prevention Strategies
| Technique | Effectiveness | Implementation |
|---|---|---|
| Strip \r and \n | High | Remove all CR/LF from header values |
| Whitelist validation | Highest | Only allow expected characters (e.g., alphanumeric) |
| URL-encode output | High | Encode values placed in Location headers |
| Use framework APIs | High | Modern frameworks reject CRLF in headers |
| Avoid user input in headers | Highest | Use server-side mappings instead of direct input |
Safe Implementation: Node.js / Express
1const express = require('express');
2const app = express();
3
4// Helper: strip CRLF from any string
5function stripCRLF(value) {
6 return value.replace(/[\r\n]/g, '');
7}
8
9// Helper: validate against a whitelist
10function isValidLocale(lang) {
11 const allowed = ['en', 'es', 'fr', 'de', 'ja', 'zh'];
12 return allowed.includes(lang);
13}
14
15// SAFE: Whitelist approach (best)
16app.get('/set-language', (req, res) => {
17 const lang = req.query.lang;
18 if (!isValidLocale(lang)) {
19 return res.status(400).send('Invalid language');
20 }
21 res.cookie('lang', lang, { httpOnly: true, sameSite: 'lax' });
22 res.redirect('/');
23});
24
25// SAFE: Strip CRLF approach
26app.get('/download', (req, res) => {
27 let filename = req.query.file || 'download.txt';
28 filename = stripCRLF(filename);
29 filename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
30 res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
31 res.send(fileData);
32});
33
34// SAFE: URL-encode for redirects
35app.get('/redirect', (req, res) => {
36 const target = req.query.url;
37 // Validate URL and use encodeURI
38 try {
39 const parsed = new URL(target, 'https://mysite.com');
40 if (parsed.origin !== 'https://mysite.com') {
41 return res.status(400).send('Invalid redirect');
42 }
43 res.redirect(parsed.href);
44 } catch {
45 res.status(400).send('Malformed URL');
46 }
47});Safe Implementation: Python / Django
1import re
2from django.http import HttpResponse, HttpResponseRedirect
3from django.utils.http import url_has_allowed_host_and_scheme
4
5def strip_crlf(value):
6 """Remove all CR and LF characters."""
7 return re.sub(r'[\r\n]', '', value)
8
9# SAFE: Whitelist for cookie values
10VALID_THEMES = {'light', 'dark', 'auto'}
11
12def set_theme(request):
13 theme = request.GET.get('theme', 'light')
14 if theme not in VALID_THEMES:
15 return HttpResponse('Invalid theme', status=400)
16 response = HttpResponse(status=302)
17 response.set_cookie('theme', theme, httponly=True, samesite='Lax')
18 response['Location'] = '/'
19 return response
20
21# SAFE: Validate redirect URL
22def safe_redirect(request):
23 target = request.GET.get('next', '/')
24 if not url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}):
25 target = '/'
26 return HttpResponseRedirect(target)
27
28# SAFE: Sanitize Content-Disposition filename
29def download(request):
30 filename = request.GET.get('file', 'download.txt')
31 filename = re.sub(r'[^a-zA-Z0-9._-]', '_', filename)
32 response = HttpResponse(get_file(filename))
33 response['Content-Disposition'] = f'attachment; filename="{filename}"'
34 return responseBest Practice Summary
Prefer whitelisting over blacklisting or stripping—define exactly what is allowed rather than trying to block all dangerous characters.
Use typed APIs like res.cookie() or response.set_cookie() instead of manually constructing Set-Cookie strings.
Validate redirect targets against an allowlist of domains.
Keep frameworks updated—modern versions of Express, Django, Spring, and Rails reject CRLF in header values by default.