Software Securityhome
Fix a findingSearch
Fix card · A09:2025 Security Logging and Alerting Failures

Fix CRLF and log injection in Python and Flask

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

In short

Log events as structured data: pass request values to the logger as fields and format each record as one JSON object, so CR, LF and escape characters are encoded and cannot end it. For download names, keep only letters, digits and a few separators, then let send_file(download_name=...) write Content-Disposition. Never interpolate request data into log lines or headers.

On this page

Steps

  1. Find every logging call and header write that takes request data: logger.warning('... %s', request.form[...]), f-strings in log messages, response.headers[...] = f'...', and Content-Disposition, Location and Set-Cookie built from request.args or request.form.
  2. Give the audit logger a JSON formatter (or python-json-logger, structlog) and pass request values in extra=, never in the message.
  3. If a log must stay plain text, encode instead of deleting: escape every control character (C0, DEL and C1) and the field separator.
  4. Build download names from an allowlist (\w, space, dot, hyphen; \w is Unicode-aware for str patterns), strip leading and trailing separators, cap the length and fall back to a fixed name.
  5. Return the file with send_file(..., as_attachment=True, download_name=name), which quotes the name and adds an RFC 5987 filename* with an ASCII fallback for non-ASCII names.
  6. Add regression tests that sign in with LF, CR, CRLF and ESC in the username and read the log back with open() in text mode (universal newlines), and that download with CR, LF, quotes and non-Latin names.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.pyPython
def formatter() -> logging.Formatter:
    return logging.Formatter("%(asctime)s %(levelname)s %(message)s")


def log_login_failed(logger: logging.Logger, username: str, ip: str) -> None:
    logger.warning("login failed user=%s ip=%s", username, ip)


def log_login_succeeded(logger: logging.Logger, username: str, ip: str) -> None:
    logger.info("login succeeded user=%s ip=%s", username, ip)

With IMPL=vulnerable, 8 of 13 tests fail (7 exploit tests).

The vulnerable code: the download header

Vulnerableimpl/vulnerable.pyPython
def send_csv(name: str, csv: str) -> Response:
    response = Response(csv, mimetype="text/csv")
    response.headers["Content-Disposition"] = f'attachment; filename="{name}.csv"'
    return response

With IMPL=vulnerable, 8 of 13 tests fail (7 exploit tests).

The fix: a JSON formatter and fields

Fixedimpl/fixed.pyPython
# json.dumps escapes ", \ and every character below U+0020 (CR, LF, ESC...); DEL, the C1 controls
# and U+2028/U+2029 are escaped as well, so no reader can split or rewrite a record.
_ALSO_ESCAPE = re.compile("[\u007f-\u009f

]")


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        event = {
            "time": datetime.fromtimestamp(record.created, UTC).isoformat(timespec="milliseconds"),
            "level": record.levelname,
            "message": record.getMessage(),
            **getattr(record, "fields", {}),
        }
        line = json.dumps(event, ensure_ascii=False)
        return _ALSO_ESCAPE.sub(lambda match: f"\\u{ord(match.group()):04x}", line)


def formatter() -> logging.Formatter:
    return JsonFormatter()


# Request data travels as fields, never inside the message text.
def log_login_failed(logger: logging.Logger, username: str, ip: str) -> None:
    logger.warning("login failed", extra={"fields": {"event": "login_failed", "user": username, "ip": ip}})


def log_login_succeeded(logger: logging.Logger, username: str, ip: str) -> None:
    logger.info("login succeeded", extra={"fields": {"event": "login_succeeded", "user": username, "ip": ip}})

With IMPL=fixed, 13 of 13 tests pass.

The fix: a clean name and send_file()

Fixedimpl/fixed.pyPython
# Keep letters and digits in any script, spaces, dots, hyphens and underscores (\w is Unicode-aware
# for str patterns), and replace everything else, including control characters and quotes.
_UNSAFE = re.compile(r"[^\w .-]+")


def safe_file_name(name: str) -> str:
    cleaned = _UNSAFE.sub("_", unicodedata.normalize("NFC", name)).strip(" ._")[:100]
    return cleaned or "export"


def send_csv(name: str, csv: str) -> Response:
    # send_file quotes the name and, for non-ASCII names, adds an RFC 5987 filename* parameter
    # with an ASCII fallback in filename.
    return send_file(
        io.BytesIO(csv.encode("utf-8")),
        mimetype="text/csv",
        as_attachment=True,
        download_name=f"{safe_file_name(name)}.csv",
    )

With IMPL=fixed, 13 of 13 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
def strip_newlines(value: str) -> str:
    return value.replace("\n", "")


def formatter() -> logging.Formatter:
    return logging.Formatter("%(asctime)s %(levelname)s %(message)s")


def log_login_failed(logger: logging.Logger, username: str, ip: str) -> None:
    logger.warning("login failed user=%s ip=%s", strip_newlines(username), ip)


def log_login_succeeded(logger: logging.Logger, username: str, ip: str) -> None:
    logger.info("login succeeded user=%s ip=%s", strip_newlines(username), ip)

With IMPL=wrong, 6 of 13 tests fail (5 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/test_crlf.pyPython
class TestExploit:
    @pytest.mark.parametrize("separator", ["\n", "\r", "\r\n"], ids=["LF", "lone CR", "CRLF"])
    def test_a_line_break_in_the_username_cannot_add_a_forged_log_record(self, client, log_file, separator):
        sign_in(client, f"alice{separator}{FORGED}")
        records = log_records(log_file)
        assert len(records) == 1, f"one failed sign-in became {len(records)} records: {records}"
        assert not any(record.startswith(FORGED) for record in records)

    def test_terminal_escape_sequences_in_the_username_do_not_reach_the_log_raw(self, client, log_file):
        # ESC [2K erases the line and ESC [1A moves the cursor up: in a terminal, tail -f or cat
        # of the log would rewrite what the reader sees.
        sign_in(client, "alice\x1b[2K\x1b[1A")
        assert "\x1b" not in log_file.read_text(encoding="utf-8")

    @pytest.mark.parametrize("separator", ["\r\n", "\r"], ids=["CRLF", "lone CR"])
    def test_a_line_break_in_the_file_name_neither_adds_a_header_nor_breaks_the_download(self, client, separator):
        response = client.get("/export", query_string={"name": f"report{separator}Set-Cookie: session=attacker"})
        name = saved_name(response)
        assert "Set-Cookie" not in response.headers
        assert not any(ord(c) < 0x20 or ord(c) == 0x7F for c in name), repr(name)
        assert name.endswith(".csv")

    def test_a_double_quote_in_the_file_name_cannot_add_a_filename_star_parameter(self, client):
        # Browsers prefer filename* (RFC 6266), so this would save the CSV as evil.html.
        response = client.get("/export", query_string={"name": "report\"; filename*=UTF-8''evil.html; x=\""})
        assert saved_name(response).endswith(".csv")

Pitfalls

  • logging formats %s arguments as they are: Python's logging module does no escaping, so logger.warning('login failed user=%s', username) writes whatever the username holds.
  • Deleting \n is not enough. open() in text mode reads a lone CR as a line end (universal newlines), so the wrong fix's replace('\n', '') still lets alice\r<forged record> through, and CodeQL's py/log-injection counts exactly that replace() as a sanitizer.
  • Werkzeug raises ValueError ('Header values must not contain newline characters') for a CR or LF in any header value, so response splitting fails in Flask: the hand-built header turns the attack into a 500 instead. send_file(download_name=...) hits the same check, so clean the name before you pass it.
  • A double quote in a hand-built header ends the quoted file name: report"; filename*=UTF-8''evil.html; x=" adds a filename* parameter that browsers prefer (RFC 6266), so the CSV is saved as evil.html.
  • WSGI servers send header values as ISO-8859-1 (PEP 3333), so a hand-built Content-Disposition with a name such as 売上 2026 fails on a real server even when the Flask test client accepts it; send_file writes such names as filename*.
  • json.dumps escapes only ", \ and characters below U+0020 when ensure_ascii=False. DEL, the C1 controls (U+0080 to U+009F, including NEL) and U+2028/U+2029 stay raw unless you escape them too, as the fixed formatter does; str.splitlines() treats several of them as line breaks.
  • Never log the password, and keep failed-sign-in usernames short and encoded: people often type their password into the username field.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs crlf-log-injection/python-flask does this for you).
  2. Against your own app, sign in with a username containing %0D followed by a fake success record and confirm the log shows one failed attempt, then request a download whose name contains %0D%0A and %22 and check it is a normal download whose name ends in your extension.
  3. Expect static analysis to mislead here: Semgrep and Bandit have no rule for it, and CodeQL accepts the wrong fix and reports the fixed one, so the regression tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix CRLF and log injection in Python and Flask
ToolRuleWhat it found here
Semgrepp/default, p/security-audit, p/owasp-top-ten, p/python, p/flaskNo log or header finding on any implementation. The only hit was python.lang.security.audit.non-literal-import on app.py's IMPL loader, which is the card's test harness. The registry's CRLF rules are Java-only, and a single-file probe that passes request.form into logging.warning and an f-string into response.headers drew nothing either.Checked: Semgrep 1.180.0 (CE); semgrep scan --config <pack> app.py impl
CodeQLpy/log-injectionFrom the source: the sink is any input of a logging call, the msg argument, the format arguments and extra=; the sanitizer is a .replace() whose first argument is the literal "\n" or "\r\n", with a comment that it does not check that every kind of line break is replaced. So it should report the vulnerable code, accept the wrong fix's replace("\n", "") although a lone CR still forges a record, and also report the fixed code, because the username still reaches extra= and the encoding happens later, in the formatter.Checked: github/codeql main at b5b8165; not executed
CodeQLpy/http-response-splittingNot expected to flag this card: its sinks are header writes whose value can carry a newline, and the Flask and Werkzeug models declare valueAllowsNewline() as none, matching Werkzeug's ValueError. The quote injection that does work is outside the query's model.Checked: github/codeql main at b5b8165; not executed
Banditn/aNo log or header injection pluginNo findings. Bandit has no plugin for CWE-117 or CWE-113.Checked: Bandit 1.9.4; bandit -r app.py impl

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
flask3.1.3
werkzeug3.1.9
logging, json (standard library)Python 3.14.8
pytest9.1.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the crlf-log-injection/python-flaskfolder.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest

# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest

# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytest

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs crlf-log-injection/python-flask runs all three modes and checks the expected results.

Download the project ZIP, 20 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python CPython 3.14.8
  • flask 3.1.3
  • werkzeug 3.1.9
  • pytest 9.1.1
  • semgrep 1.180.0
  • bandit 1.9.4
Matrix
fixed: 13 of 13 tests pass; vulnerable: 8 of 13 tests fail (7 exploit tests); wrong: 6 of 13 tests fail (5 exploit tests)
Test results
fixed 13/13 pass; vulnerable 8/13 fail (7 exploit tests); wrong 6/13 fail (5 exploit tests), also offline after install with outbound network blocked
Dependency freshness
flask 3.1.3 was published on 2026-02-19, werkzeug 3.1.9 on 2026-09-27 (a security release) and pytest 9.1.1 on 2026-06-19; iniconfig is held at 2.3.0 because 2.3.1 was four days old. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Logging Cheat Sheet (event data sanitization)
  2. Python logging: LogRecord attributes and the extra argument
  3. Python json: json.dumps and ensure_ascii
  4. Python glossary: universal newlines
  5. Flask API: send_file
  6. Werkzeug: Headers (newlines raise ValueError)
  7. PEP 3333: response headers are ISO-8859-1 strings
  8. RFC 6266: Use of the Content-Disposition Header Field in HTTP
  9. CWE-117: Improper Output Neutralization for Logs
  10. CodeQL query help: py/log-injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.