HTTP uses carriage return followed by line feed (\r\n) as the delimiter between headers, and as the separator between headers and body. That design is three decades old, and it becomes a vulnerability whenever an application includes user-controlled input in an HTTP response header without stripping those characters.
The attack has a name — CRLF injection, also called HTTP response splitting when the injected characters create artificial response boundaries — and it enables a cluster of secondary attacks: cache poisoning, XSS, session fixation, and log forgery. It shows up regularly in penetration tests despite being well understood, because developers think about sanitising values for HTML output and SQL queries but rarely for HTTP header values.
The Mechanics
HTTP headers look like this:
HTTP/1.1 302 Found\r\n
Location: https://example.com/page\r\n
Set-Cookie: session=abc123\r\n
\r\n
The \r\n sequences are header terminators. A double \r\n marks the boundary between headers and body. If you inject a \r\n inside a header value, you create a new header. If you inject \r\n\r\n, you end the header section and start an arbitrary body.
A vulnerable redirect endpoint:
from flask import Flask, redirect, request
app = Flask(__name__)
@app.route('/redirect')
def unsafe_redirect():
url = request.args.get('url', '/')
return redirect(url) # url is included in Location header
An attacker crafts this request:
GET /redirect?url=https://example.com%0d%0aSet-Cookie:+session=attacker_session HTTP/1.1
The %0d%0a URL-decodes to \r\n. The resulting response header:
HTTP/1.1 302 Found
Location: https://example.com
Set-Cookie: session=attacker_session
The server just set a cookie on behalf of the attacker.
Attack: HTTP Response Splitting
With a \r\n\r\n injection, an attacker can append a complete fake response body:
GET /redirect?url=https://example.com%0d%0a%0d%0a<html><script>alert(document.cookie)</script></html> HTTP/1.1
Resulting response:
HTTP/1.1 302 Found
Location: https://example.com
<html><script>alert(document.cookie)</script></html>
In practice, modern browsers follow the redirect and ignore the injected body. The real danger is in intermediate HTTP caches. If the forged response reaches a caching proxy, the proxy may cache the injected body as the canonical response for the target URL. Subsequent users requesting that URL receive the attacker’s content — this is cache poisoning.
Attack: Log Injection
Log injection is the same vulnerability applied to log files rather than HTTP responses. If user input appears in application logs without sanitisation, an attacker can inject fake log entries:
import logging
logger = logging.getLogger(__name__)
@app.route('/login', methods=['POST'])
def login():
username = request.form.get('username')
logger.info(f"Login attempt for user: {username}")
# ...
An attacker submits a username of:
admin\nINFO 2026-06-28 Login successful for user: admin\nINFO 2026-06-28 Privilege escalated to root
The resulting log file contains fabricated entries indistinguishable from legitimate ones. This is used to cover tracks, confuse incident response, and inject false entries that trigger or suppress automated alerting.
Vulnerable Patterns in Node.js/Express
Express does sanitise headers in recent versions, but older code and lower-level frameworks do not:
// Vulnerable in older versions of Express or http module directly
const http = require('http');
http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
const redirectTarget = url.searchParams.get('to');
// Direct header injection — vulnerable
res.writeHead(302, { 'Location': redirectTarget });
res.end();
}).listen(3000);
// Also vulnerable: setting custom headers from user input
app.get('/api/data', (req, res) => {
const format = req.query.format;
res.setHeader('Content-Type', `application/${format}`); // CRLF injectable
res.send(data);
});
Fixes
Strip or encode CRLF characters from all values placed in HTTP headers.
In Python:
import re
def sanitise_header_value(value: str) -> str:
# Remove all CR and LF characters
return re.sub(r'[\r\n]', '', value)
@app.route('/redirect')
def safe_redirect():
url = request.args.get('url', '/')
safe_url = sanitise_header_value(url)
return redirect(safe_url)
For redirects specifically, validate the URL is to an allowed domain before redirecting:
from urllib.parse import urlparse
ALLOWED_REDIRECT_DOMAINS = {'example.com', 'app.example.com'}
def safe_external_redirect(url: str):
parsed = urlparse(url)
if parsed.netloc not in ALLOWED_REDIRECT_DOMAINS:
return redirect('/')
safe_url = re.sub(r'[\r\n]', '', url)
return redirect(safe_url)
In Node.js, use the encodeURI or validate explicitly:
const { URL } = require('url');
function safeRedirect(res, target) {
// Validate URL is to an allowed origin
try {
const parsed = new URL(target);
const allowed = ['https://example.com', 'https://app.example.com'];
if (!allowed.some(origin => parsed.origin === origin)) {
res.redirect('/');
return;
}
// Strip CRLF just in case
const safe = target.replace(/[\r\n]/g, '');
res.redirect(safe);
} catch {
res.redirect('/');
}
}
For logging, encode newlines in user input before writing:
def safe_log_value(value: str) -> str:
return value.replace('\n', '\\n').replace('\r', '\\r')
logger.info(f"Login attempt for user: {safe_log_value(username)}")
Structured logging libraries (structlog, python-json-logger, Winston in Node.js) serialise values as JSON strings, which naturally escape newlines. Switching to structured logging is the most durable fix for log injection.
Where to Find It
CRLF injection reliably appears in:
- Redirect endpoints that include
url,next,return_to, orredirectparameters in theLocationheader - Cookie-setting code that constructs
Set-Cookievalues from user input (custom cookie names or values) - Custom response headers that reflect request parameters (
Content-Disposition: attachment; filename=<user_input>) - Logging middleware that includes request headers, user-agent strings, or query parameters in log output without encoding
Automated scanners (Burp Suite, OWASP ZAP) detect basic CRLF injection by injecting %0d%0a in parameters and observing whether response headers change. Manual testing adds %0a, %0d, \r\n, and Unicode variants (%E5%98%8A%E5%98%8D) against redirect and cookie-setting endpoints.