Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix CSV formula injection in Python and Django

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

In short

User text in a CSV is attacker-controlled. If a cell starts with = + - @, a tab, CR, LF or NUL, prefix it with an apostrophe so a spreadsheet treats it as text, then write it with csv.writer, which escapes commas, quotes and newlines. Quoting every field (QUOTE_ALL) is valid CSV but does not stop a formula.

On this page

Steps

  1. Find every view or admin export that writes user-controlled text to CSV or TSV (Django's HttpResponse/StreamingHttpResponse with csv.writer, django-import-export, admin actions).
  2. Before writing each field, check its first character; if it is =, +, -, @, a tab, a carriage return, a line feed, a null character or a full-width = + - @, prefix the field with a single apostrophe.
  3. Write rows with csv.writer so CSV quoting still handles commas, double quotes and newlines.
  4. Apply the neutralization to every field, from every model column, because any value can carry a payload.
  5. Serve the response with content_type="text/csv" and a Content-Disposition: attachment header.
  6. Add regression tests that read the exported file with csv.reader and assert no cell begins with a formula trigger.

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

Vulnerablecontacts/impl/vulnerable.pyPython
def to_csv(records) -> str:
    out = io.StringIO()
    writer = csv.writer(out, lineterminator="\n")
    writer.writerow(["name", "note"])
    for name, note in records:
        writer.writerow([name, note])  # written exactly as the user typed it
    return out.getvalue()

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

The fix: neutralize formula triggers

Fixedcontacts/impl/fixed.pyPython
def safe_cell(value) -> str:
    text = "" if value is None else str(value)
    if text and text[0] in _TRIGGERS:
        return "'" + text  # an apostrophe makes a spreadsheet treat the cell as text
    return text


def to_csv(records) -> str:
    out = io.StringIO()
    writer = csv.writer(out, lineterminator="\n")
    writer.writerow(["name", "note"])
    for name, note in records:
        writer.writerow([safe_cell(name), safe_cell(note)])
    return out.getvalue()

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fixcontacts/impl/wrong.pyPython
def to_csv(records) -> str:
    out = io.StringIO()
    # QUOTE_ALL wraps every field in double quotes -- correct CSV, no formula defense.
    writer = csv.writer(out, lineterminator="\n", quoting=csv.QUOTE_ALL)
    writer.writerow(["name", "note"])
    for name, note in records:
        writer.writerow([name, note])
    return out.getvalue()

With IMPL=wrong, 8 of 12 tests fail (8 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_export.pyPython
# Exploit: a formula payload must be neutralized so a spreadsheet treats it as text.

def _assert_neutralized(client, payload):
    cell = cell_for(data_cells(export(client)), payload)
    assert cell and cell[0] not in TRIGGERS, f"{payload!r} is exported as {cell!r}, which a spreadsheet runs as a formula"


def test_exploit_equals_formula_is_neutralized(client):
    _assert_neutralized(client, "=1+1")


def test_exploit_plus_formula_is_neutralized(client):
    _assert_neutralized(client, "+1+1")


def test_exploit_minus_formula_is_neutralized(client):
    _assert_neutralized(client, "-1+1")


def test_exploit_at_formula_is_neutralized(client):
    _assert_neutralized(client, "@SUM(1+1)")


def test_exploit_leading_tab_is_neutralized(client):
    _assert_neutralized(client, "\t=1+1")


def test_exploit_leading_null_is_neutralized(client):
    _assert_neutralized(client, "\x00=1+1")


def test_exploit_fullwidth_equals_is_neutralized(client):
    # U+FF1D FULLWIDTH EQUALS SIGN, which some locales' spreadsheets also treat as a formula.
    _assert_neutralized(client, "\uff1d1+1")


def test_exploit_dde_hyperlink_is_neutralized(client):
    _assert_neutralized(client, '=HYPERLINK("http://attacker.example","click")')

Pitfalls

  • CSV quoting is not a formula defense: QUOTE_ALL wraps =1+1 in double quotes (valid CSV), but the cell value is still =1+1 and a spreadsheet evaluates it.
  • Control characters count too: OWASP lists tab (0x09), carriage return (0x0D) and line feed (0x0A), and ASVS 5.0.0 1.2.10 adds the null character; some spreadsheets strip them and then see the =. OWASP also lists the full-width forms = + - @.
  • The payload can be in any model field, including ones you think are numeric or a name; neutralize every field.
  • A negative number like -5 starts with a trigger, so free text such as "-5 units" gets an apostrophe too. Do not skip '-' for that; validate genuinely numeric columns as numbers on input and write the number.
  • Escaping or stripping the characters can corrupt legitimate data; the apostrophe keeps the value and only changes how the spreadsheet reads it.
  • The same risk applies to TSV, to the Django admin's CSV exports, and to values later pasted into a cell; apply the control wherever the value reaches a spreadsheet.
  • Semgrep's use-defusedcsv rule recommends the defusedcsv package as a drop-in csv replacement. Its 3.0.0 release prefixes an apostrophe only to fields starting with = + - @ | or %, skips values that look like numbers, and leaves a leading tab, CR, LF, NUL or full-width character alone, so check what any library neutralizes against ASVS 5.0.0 1.2.10 before relying on it.

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 csv-formula-injection/python-django does this for you).
  2. Export a file with cells like =1+1, @SUM(1+1) and a leading tab, read it back with csv.reader, and confirm no cell begins with a formula trigger; confirm benign values and commas/quotes still round-trip.
  3. Semgrep's use-defusedcsv rule flags every csv.writer() call, the fixed one included, and its framework csv-writer-injection rule found nothing because the data comes from storage rather than the request; Bandit and CodeQL have nothing for this weakness. The regression tests are the control, and opening the file in a spreadsheet is the manual check.

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 CSV formula injection in Python and Django
ToolRuleWhat it found here
Semgreppython.lang.security.use-defusedcsv.use-defusedcsvThree findings (INFO, audit): every csv.writer() call, in contacts/impl/vulnerable.py, wrong.py and fixed.py alike. The rule flags any use of the standard csv module and suggests defusedcsv, so it cannot tell the fixed code from the vulnerable code. defusedcsv 3.0.0 (read from its wheel) prefixes an apostrophe to fields starting with = + - @ | or %, but not to a leading tab, CR, LF, NUL or full-width form, so it would fail this card's tab, NUL and full-width tests.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3; semgrep scan --metrics=off --config p/python --config p/owasp-top-ten --config p/security-audit --config p/django --config r/python.lang.security.use-defusedcsv.use-defusedcsv --config r/python.django.security.injection.csv-writer-injection.csv-writer-injection --exclude tests .
Semgreppython.django.security.injection.csv-writer-injection.csv-writer-injectionNo findings: it is file-local taint from request data into csv.writer(...).writerow(), and here the records are stored data (a module constant standing in for the database) written in a separate module. Stored user input is exactly where formula injection usually comes from, so taint from the request is the wrong model for this weakness.Checked: same run
Banditall plugins (default profile)Bandit 1.9.4No findings in any implementation. Bandit has no plugin for CSV formula injection, so its silence says nothing about this weakness.Checked: Bandit 1.9.4 (pip install bandit==1.9.4 in a scratch venv, Python 3.14.8); bandit -r . -x ./.venv,./tests

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkDjango 6.1.2
django6.1.2
csv, io (standard library)Python 3.14.8
pytest9.1.1
pytest-django4.14.0

Run the tests

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

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 csv-formula-injection/python-django 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 3.14.8
  • pip 26.2.1
  • django 6.1.2
  • pytest 9.1.1
  • pytest-django 4.14.0
Matrix
fixed: 12 of 12 tests pass; vulnerable: 8 of 12 tests fail (8 exploit tests); wrong: 8 of 12 tests fail (8 exploit tests)
Test results
IMPL=fixed 12/12 pass; IMPL=vulnerable 8/12 fail (all 8 exploit: the =, +, -, @, leading-tab, leading-NUL, full-width = and HYPERLINK cells keep their trigger); IMPL=wrong 8/12 fail (the same 8: QUOTE_ALL is valid CSV but does not neutralize the cell).
Dependency freshness
django 6.1.2 (2026-10-06, a security release), pytest-django 4.14.0 (2026-08-10) and pytest 9.1.1 (2026-06-19) are the latest, matching the other Django cards. Python 3.14.8 is the 30 September 2026 security release.
Scanners
Semgrep 1.180.0 in the pinned semgrep/semgrep image and Bandit 1.9.4 from a scratch venv, run (see scannerRules); no CodeQL query covers this weakness.

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. CWE-1236: Improper Neutralization of Formula Elements in a CSV File
  2. OWASP CSV Injection
  3. OWASP WSTG (latest): CSV Injection (WSTG-INJT-21)
  4. Python csv module
  5. Django 6.1: Outputting CSV with Django

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.