Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Formats (JSON, YAML, TOML, XML, CSV)  Module 9 – CSV and tabular text

Safe CSV exports and formula injection

Why a CSV cell that starts with = runs as a formula in a spreadsheet, how to neutralise it without breaking numbers, and how to import CSV safely.

  • Intermediate
  • 15 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain how a formula in a CSV cell becomes a risk for the people who open the file
  • Protect spreadsheet exports by neutralising risky cells without changing declared number columns
  • Write one export for spreadsheets and one for programs from the same rows, each checked by a test
  • Validate imported CSV rows against an allowlist and load them with placeholders

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain how a formula in a CSV cell becomes a risk for the people who open the file
  • Protect spreadsheet exports by neutralising risky cells without changing declared number columns
  • Write one export for spreadsheets and one for programs from the same rows, each checked by a test
  • Validate imported CSV rows against an allowlist and load them with placeholders

Before you start

On this page

A customer types =2+5 as the subject of a support ticket, and the nightly export copies it into tickets.csv unchanged. A colleague opens the file in a spreadsheet and the cell shows 7: the spreadsheet ran a formula that a stranger wrote, and a formula can do far more than add.

You will see why spreadsheets do this and who is at risk, neutralise the risky cells of an export with a check that fails before the fix and passes after it, keep number columns intact, publish a second export for programs, and validate the rows that travel the other way, from an upload into your database.

A spreadsheet runs any cell that starts like a formula

CSV has no types, so the program that opens a file decides what each cell is. A spreadsheet reads a cell whose text starts with = as a formula, and OWASP lists the other first characters that do the same in common spreadsheets: +, -, @, a tab, a carriage return, a line feed, and full-width forms such as =, +, - and @, which some locales also treat as formulas. Papa Parse’s documentation gives the same reason for its option that guards against them: Excel and LibreOffice parse such cells as formulae. Step through one ticket subject on its way to a spreadsheet:

Interactive · message by message From a ticket form to a formula in a spreadsheet Step through one ticket subject on its way from a customer to a colleague's spreadsheet, first without the fix and then with it.
From a ticket form to a formula in a spreadsheet, step 7 of 7. Messages so far: Customer to Support app: subject: =2+5; Support app to Spreadsheet: tickets.csv: =2+5; Support app to Spreadsheet: tickets.csv: '=2+5.Customertypes a ticketSupport appstores and exportsSpreadsheeta colleague opens itsubject: =2+5tickets.csv: =2+5tickets.csv: '=2+5saved again

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. A subject that starts with =. A customer types =2+5 as the subject of a ticket. The app stores the four characters and shows them as text on its own pages, which is correct.
  2. The export copies it. A colleague asks for the tickets as CSV. CSV has no types, so the app writes the subject exactly as stored: the cell is the text =2+5.
  3. The spreadsheet decides. The colleague opens tickets.csv. The cell starts with =, so the spreadsheet reads it as a formula, runs it and shows 7. Nothing warns that the value came from a stranger.
  4. Why it matters. The report is already wrong, and arithmetic is the mildest case: OWASP describes formula injection as a way to attack the people who open an export, through their spreadsheet.
  5. The fix in the export. The safe export puts an apostrophe in front of every text cell that starts with =, +, -, @, a tab or a line break, and their full-width forms. It writes the cell as the text with the apostrophe in front.
  6. Shown as text. A spreadsheet treats an entry that starts with an apostrophe as text, so the cell shows =2+5 and nothing runs. The refund column, a declared number, is written without one.
  7. Only for reading. OWASP warns that Excel may drop such protection when a file is saved and opened again. Treat the safe file as a file for people to read, and give programs an export of their own.

This is formula injection: A weakness of programs that export outside text to CSV: a cell that starts with =, +, - or @, or a tab or a line break, is run as a formula by the spreadsheet that opens the file. Also called CSV injection; an export prevents it by neutralising such cells., also called CSV injection. The program that wrote the export is never harmed: the formula runs in the spreadsheet of whoever opens the file, often a colleague in finance or support who trusts it because it came from your own system. Even harmless arithmetic does damage, since the report now shows 7 where the customer wrote =2+5, and OWASP describes how crafted formulas are used against the people who open such files.

Any export that copies text from outside is exposed: customer names, addresses, ticket subjects, comments, product titles from a supplier’s feed. The defence belongs in the export, the one place that knows which cells hold untrusted text, and it needs a test, because nothing in the file looks wrong to the program that wrote it.

Checkpoint

Check: How a cell becomes a formula

  1. Question 1 of 2 Which first characters of a cell does OWASP list as making a spreadsheet read it as a formula?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • A tab, a carriage return or a line feed
    • = and +
    • - and @

    OWASP's list is =, +, -, @, tab, carriage return and line feed, plus full-width forms such as the full-width equals sign, which some locales also read as formulas.

  2. Question 2 of 2 A support app exports ticket subjects typed by customers. Who is at risk when a subject starts with =?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The colleague who opens the export in a spreadsheet

    Formula injection turns your export into a carrier: the program is safe, the reader is not. That is why the defence belongs in the export and why it needs a test.

Neutralise the first character, after a real CSV writer has quoted the field

A cell is neutralised when a spreadsheet can no longer read it as a formula. The common way, which OWASP describes, is to put a single quote in front of the value: Microsoft documents that Excel treats an entry starting with an apostrophe as text. That alone is not enough if the file is built by joining values with commas, because a comma inside a value starts a new cell, and the new cell can start with anything. This program writes five tickets both ways, then runs a check over each file: every cell a spreadsheet would read as a formula, and every record of the wrong length, is a problem. The JavaScript tab writes the same safe file:

A weak export, a safe export and the check

Python · neutralise/export_safety.py

"""A ticket export written two ways, and a check that fails on the first and passes on the second."""

import csv
import io
import re

COLUMNS = ["id", "customer", "subject", "refund"]
NUMERIC = {"refund"}
TICKETS = [
    {"id": "T-101", "customer": "Asha Iyer", "subject": "=2+5", "refund": -250},
    {"id": "T-102", "customer": "Rao, Vikram", "subject": "@mention in the invoice", "refund": 0},
    {"id": "T-103", "customer": "Meena", "subject": "Call back,+91 98765 43210", "refund": 1200},
    {"id": "T-104", "customer": "Kiran", "subject": "\tpasted with a tab", "refund": -99},
    {"id": "T-105", "customer": "Ravi", "subject": "\uff1dSUM(1,2)", "refund": 0},
]
# The first characters that make a spreadsheet read a cell as a formula, full-width forms included.
RISKY = ("=", "+", "-", "@", "\t", "\r", "\n", "\uff1d", "\uff0b", "\uff0d", "\uff20")
NUMBER = re.compile(r"-?\d+(?:\.\d+)?")


def weak_export(rows):
    """Joins the values with commas, as a quick script does."""
    lines = [",".join(COLUMNS)] + [",".join(str(row[c]) for c in COLUMNS) for row in rows]
    return "\r\n".join(lines) + "\r\n"


def neutralise(text):
    """A text cell that a spreadsheet would run as a formula gets an apostrophe in front of it."""
    return "'" + text if text.startswith(RISKY) else text


def safe_export(rows):
    """csv.writer quotes what needs quotes; text is neutralised, declared numbers are written as numbers."""
    out = io.StringIO()
    writer = csv.writer(out)
    writer.writerow(COLUMNS)
    for row in rows:
        writer.writerow([row[c] if c in NUMERIC else neutralise(str(row[c])) for c in COLUMNS])
    return out.getvalue()


def risky_cells(text):
    """The check: every cell a spreadsheet would read as a formula, and every record of the wrong length."""
    reader = csv.reader(io.StringIO(text, newline=""))
    header = next(reader)
    problems = []
    for fields in reader:
        if len(fields) != len(header):
            problems.append(f"line {reader.line_num}: {len(fields)} fields, the header has {len(header)}")
        for i, value in enumerate(fields):
            column = header[i] if i < len(header) else f"field {i + 1}"
            if column in NUMERIC and NUMBER.fullmatch(value):
                continue
            if value.startswith(RISKY):
                problems.append(f"line {reader.line_num}, {column}: {value!r}")
    return problems


for name, export in [("weak", weak_export(TICKETS)), ("safe", safe_export(TICKETS))]:
    problems = risky_cells(export)
    print(f"{name} export: {'FAIL' if problems else 'PASS'}")
    for problem in problems:
        print("  " + problem)

print()
print(safe_export(TICKETS).replace("\t", "\\t").replace("\r\n", "\\r\\n\n"), end="")

Output

weak export: FAIL
  line 2, subject: '=2+5'
  line 3: 5 fields, the header has 4
  line 3, refund: '@mention in the invoice'
  line 4: 5 fields, the header has 4
  line 4, refund: '+91 98765 43210'
  line 5, subject: '\tpasted with a tab'
  line 6: 5 fields, the header has 4
  line 6, subject: '=SUM(1'
safe export: PASS

id,customer,subject,refund\r\n
T-101,Asha Iyer,'=2+5,-250\r\n
T-102,"Rao, Vikram",'@mention in the invoice,0\r\n
T-103,Meena,"Call back,+91 98765 43210",1200\r\n
T-104,Kiran,'\tpasted with a tab,-99\r\n
T-105,Ravi,"'=SUM(1,2)",0\r\n

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 export_safety.py

JavaScript · neutralise/export_safety.mjs

// The safe export in JavaScript: neutralise the text cells, then quote each field as RFC 4180 asks.
const COLUMNS = ["id", "customer", "subject", "refund"];
const NUMERIC = new Set(["refund"]);
const tickets = [
  { id: "T-101", customer: "Asha Iyer", subject: "=2+5", refund: -250 },
  { id: "T-102", customer: "Rao, Vikram", subject: "@mention in the invoice", refund: 0 },
  { id: "T-103", customer: "Meena", subject: "Call back,+91 98765 43210", refund: 1200 },
  { id: "T-104", customer: "Kiran", subject: "\tpasted with a tab", refund: -99 },
  { id: "T-105", customer: "Ravi", subject: "\uff1dSUM(1,2)", refund: 0 },
];

// The first characters that make a spreadsheet read a cell as a formula, full-width forms included.
const RISKY = /^[=+\-@\t\r\n\uff1d\uff0b\uff0d\uff20]/;
const neutralise = (text) => (RISKY.test(text) ? `'${text}` : text);
const field = (value) => (/[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value);

const lines = [COLUMNS, ...tickets.map((t) => COLUMNS.map((c) => (NUMERIC.has(c) ? String(t[c]) : neutralise(String(t[c])))))];
const text = lines.map((cells) => cells.map(field).join(",") + "\r\n").join("");
console.log(text.replaceAll("\t", "\\t").replaceAll("\r\n", "\\r\\n\n").replace(/\n$/, ""));

Output

id,customer,subject,refund\r\n
T-101,Asha Iyer,'=2+5,-250\r\n
T-102,"Rao, Vikram",'@mention in the invoice,0\r\n
T-103,Meena,"Call back,+91 98765 43210",1200\r\n
T-104,Kiran,'\tpasted with a tab,-99\r\n
T-105,Ravi,"'=SUM(1,2)",0\r\n

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node export_safety.mjs

The weak export fails in eight places. The formula =2+5 and the pasted tab are there as written, and the commas in Rao, Vikram, Call back,+91 98765 43210 and =SUM(1,2) made extra cells: one of them, +91 98765 43210, now sits in the refund column and starts with +. The safe export passes. csv.writer quoted the three values with commas, so they stay one cell each, and neutralise() put an apostrophe in front of the four subjects that start like a formula, the full-width = included. The refunds -250 and -99 are untouched: the refund column is declared as a number, and a number is written from the program’s own value, never from outside text.

Move the formula

Press Edit, change the subject of T-101 to "Team =2+5" and run it again. Neither export reports line 2 any more, because only a cell’s first character makes a spreadsheet read it as a formula.

So the rule needs to look at one character per cell, and it must see every cell, which only a real writer and a check of the finished file guarantee.

Deleting the risky characters

Wrong: removing =, +, - and @ from text, which changes names, phone numbers such as +91 98765 43210 and notes such as -damaged box, and still misses the full-width forms. Right: keep the text as it is and put an apostrophe in front of it, so a person reading the file sees exactly what was typed.

OWASP adds a warning: once someone saves the file in Excel and opens it again, the quoting and escaping that protected a cell may be gone, and a neutralised cell can become a formula once more. No escaping keeps a CSV file safe in every program and through every save, which is why the next section gives programs a file of their own.

Key idea

Write CSV with a real writer, so that every comma stays inside its quotes, then put an apostrophe in front of every text cell that starts like a formula, and keep declared numbers as numbers.

Checkpoint

Check: Neutralising risky cells

  1. Question 1 of 2 The weak export joins the values with commas. What does the subject Call back,+91 98765 43210 become in it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Two cells, the second one starting with + in the refund column

    Neutralising the first character is not enough on its own: a comma inside a value can start a new cell anywhere. csv.writer quotes the whole value, so it stays one cell.

  2. Question 2 of 2 The safe export wrote the refund -250 without an apostrophe but the note -damaged box with one. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The refund column is declared as numbers and written from the program's own value; the note is outside text

    Declare which columns hold numbers, write them from your own typed values, and refuse text in them. Every other column is text, and its risky first characters get an apostrophe.

Two audiences need two exports

The apostrophe protects people and corrupts data for programs: a billing import that reads '=2+5 gets a character that the customer never typed, and one that reads '-damaged box gets a note that nobody wrote. When both read your data, write two files from the same rows, one for each. This program writes refunds both ways and reads each file back with the types its documentation gives:

One set of refunds, two exports Python · exports/two_exports.py
"""The same refunds as two files, one for people with a spreadsheet and one for programs, and a round trip of each."""

import csv
import io
from decimal import Decimal

COLUMNS = ["id", "customer", "note", "refund"]
REFUNDS = [
    {"id": "R-1", "customer": "Asha Iyer", "note": "=2+5", "refund": Decimal("-250.00")},
    {"id": "R-2", "customer": "Meena", "note": "-damaged box", "refund": Decimal("1200.50")},
    {"id": "R-3", "customer": "Ravi", "note": "चाय", "refund": Decimal("0.00")},
]
RISKY = ("=", "+", "-", "@", "\t", "\r", "\n", "=", "+", "-", "@")


def write(rows, cell):
    out = io.StringIO()
    writer = csv.writer(out)
    writer.writerow(COLUMNS)
    for row in rows:
        writer.writerow([cell(c, row[c]) for c in COLUMNS])
    return out.getvalue()


def for_spreadsheets(rows):
    """UTF-8 with a BOM; text that would run as a formula gets an apostrophe; the refund stays a number."""
    def cell(column, value):
        if column == "refund":
            return str(value)
        return "'" + value if value.startswith(RISKY) else value
    return "\ufeff" + write(rows, cell)


def for_programs(rows):
    """UTF-8 without a BOM, every value exactly as it is."""
    return write(rows, lambda column, value: str(value))


def read_back(text):
    """The rows of an export, with the refund read as a Decimal, as the documentation of the file says."""
    rows = list(csv.DictReader(io.StringIO(text.removeprefix("\ufeff"), newline="")))
    return [{**row, "refund": Decimal(row["refund"])} for row in rows]


sheet, machine = for_spreadsheets(REFUNDS), for_programs(REFUNDS)
print("for spreadsheets, first bytes:", sheet.encode("utf-8")[:4].hex(" "))
print("for programs, first bytes:    ", machine.encode("utf-8")[:4].hex(" "))
print("for programs, round trip equal:", read_back(machine) == REFUNDS)
for before, after in zip(REFUNDS, read_back(sheet)):
    for column in COLUMNS:
        if before[column] != after[column]:
            print(f"for spreadsheets, {before['id']} {column}: {before[column]!r} became {after[column]!r}")
print("refunds in both files:", [str(r["refund"]) for r in read_back(sheet)] == [str(r["refund"]) for r in REFUNDS])

Output

for spreadsheets, first bytes: ef bb bf 69
for programs, first bytes:     69 64 2c 63
for programs, round trip equal: True
for spreadsheets, R-1 note: '=2+5' became "'=2+5"
for spreadsheets, R-2 note: '-damaged box' became "'-damaged box"
refunds in both files: True

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 two_exports.py

The spreadsheet file starts with ef bb bf, a byte-order mark: Microsoft’s support pages say that Excel reads a CSV file as UTF-8 without any import steps when it carries this mark, so the note चाय shows as written. The program file starts straight with the header and reads back equal to the rows it came from. The spreadsheet file differs in exactly the two neutralised notes, and the refunds are the same in both, because the refund column was never touched.

What differs The file for spreadsheets The file for programs
Encoding UTF-8 with a byte-order mark UTF-8 without one
Text that starts like a formula an apostrophe in front exactly as written
Declared numbers written as numbers written as numbers
Who reads it people, who see the text importers, with the documented types

Name the two files for their readers

Give the files names that say who they are for, such as refunds-for-spreadsheets.csv and refunds.csv, and describe both in one place: the columns, their types, the encoding and the escaping rule. An import that is pointed at the wrong file should fail on its first apostrophe, not load it.

CSV Editor & Viewer When you save a file for people, Protect against formulas puts an apostrophe before risky text and leaves plain numbers such as -12.5 alone.

Checkpoint

Check: One export for people, one for programs

  1. Question 1 of 2 A billing service imports the refunds every night. Which file should it read?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The file for programs, UTF-8 without a BOM and every value unchanged

    Each file has one reader. The spreadsheet file changes text on purpose, so a program must read the unchanged one, and a check of the first character can stop it if it is pointed at the wrong file.

  2. Question 2 of 2 Why does the file for spreadsheets start with the bytes ef bb bf?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Microsoft's documentation says Excel opens a UTF-8 CSV file normally when it was saved with a BOM

    The BOM is for the spreadsheet's sake. A reader in code handles it with utf-8-sig, but the file for programs leaves it out, as most programs expect.

Imported CSV is untrusted input, so validate rows before you load them

The same caution applies to files that come in. An upload from a customer or a partner is untrusted input, and the OWASP Input Validation Cheat Sheet’s advice fits CSV well: define what each field may hold, an allowlist of forms with a length limit, and refuse everything else, instead of hunting for bad strings. Values then reach the database through a parameterised query: An SQL statement whose values are passed apart from its text, through placeholders such as ? or :name, so a value is never read as part of the SQL. Python's sqlite3 module calls this parameter substitution., which OWASP names as the first defence against SQL injection and Python’s sqlite3 documentation recommends over building SQL with string operations. This program loads one upload both ways and checks the result:

An import that builds SQL, and one that validates and binds Python · imports/validate_import.py
"""Imported CSV is untrusted input: an import that builds SQL from the text, one that validates rows and binds
their values, and a check that fails on the first and passes on the second."""

import csv
import io
import re
import sqlite3
import unicodedata

UPLOAD = (
    "name,mobile,amount\r\n"
    "Asha Iyer,9876543210,250.00\r\n"
    "Ravi,9900011122,12e3\r\n"
    "Meena,98123,99.00\r\n"
    "Joel D'Souza,9812345678,1200.50\r\n"
    "राधा कृष्णन,9123456789,75.25\r\n"
)


def is_name(text):
    """Letters of any script with their combining marks, and spaces, apostrophes, dots and hyphens between them."""
    if not 0 < len(text) <= 80 or not text[0].isalpha() or text != text.strip():
        return False
    return all(c.isalpha() or unicodedata.category(c).startswith("M") or c in " '.-" for c in text)


# What each column may hold: an allowlist of forms, each with its length.
RULES = {
    "name": is_name,
    "mobile": lambda text: re.fullmatch(r"\d{10}", text) is not None,
    "amount": lambda text: re.fullmatch(r"\d{1,9}\.\d{2}", text) is not None,
}


def new_table():
    db = sqlite3.connect(":memory:")
    db.execute("CREATE TABLE payment (name TEXT NOT NULL, mobile TEXT NOT NULL, amount_paise INTEGER NOT NULL)")
    return db


def weak_import(text):
    """Builds each INSERT from the text of the row, and skips a row whose INSERT fails."""
    db, refused = new_table(), []
    reader = csv.DictReader(io.StringIO(text, newline=""))
    for row in reader:
        amount = row["amount"].replace(".", "")
        try:
            db.execute(f"INSERT INTO payment VALUES ('{row['name']}', '{row['mobile']}', {amount})")
        except sqlite3.Error as e:
            refused.append(f"line {reader.line_num}: {e}")
    return db, refused


def safe_import(text):
    """Checks every row against RULES, then binds the values of the rows that pass with placeholders."""
    db, refused = new_table(), []
    reader = csv.DictReader(io.StringIO(text, newline=""))
    for row in reader:
        bad = [f"{column} {row[column]!r}" for column, rule in RULES.items() if not rule(row[column] or "")]
        if bad:
            refused.append(f"line {reader.line_num}: {', '.join(bad)}")
            continue
        paise = int(row["amount"].replace(".", ""))
        db.execute("INSERT INTO payment VALUES (?, ?, ?)", (row["name"], row["mobile"], paise))
    return db, refused


def check(importer):
    """Valid rows load with their names exact, and the two invalid rows are refused with their lines."""
    db, refused = importer(UPLOAD)
    problems = []
    names = [n for (n,) in db.execute("SELECT name FROM payment ORDER BY rowid")]
    if names != ["Asha Iyer", "Joel D'Souza", "राधा कृष्णन"]:
        problems.append(f"loaded {names}")
    if [r.split(":")[0] for r in refused] != ["line 3", "line 4"]:
        problems.append(f"refused {refused}")
    return problems


for importer in (weak_import, safe_import):
    problems = check(importer)
    print(f"{importer.__name__}: {'FAIL' if problems else 'PASS'}")
    for problem in problems:
        print("  " + problem)

db, refused = safe_import(UPLOAD)
print("safe_import refused:", refused)
print("a rule built on \\w:", re.fullmatch(r"\w+(?: \w+)*", "राधा कृष्णन") is not None, "| is_name():", is_name("राधा कृष्णन"))

Output

weak_import: FAIL
  loaded ['Asha Iyer', 'Ravi', 'Meena', 'राधा कृष्णन']
  refused ['line 5: near "Souza": syntax error']
safe_import: PASS
safe_import refused: ["line 3: amount '12e3'", "line 4: mobile '98123'"]
a rule built on \w: False | is_name(): True

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 validate_import.py

The weak import loaded the wrong rows and refused a right one. It stored Ravi’s amount 12e3, which is not money in any export, and Meena’s mobile number of five digits, and then the apostrophe in Joel D'Souza ended the SQL string early: near "Souza": syntax error. A harmless name broke the statement; the same flaw lets a value written on purpose change what the statement does. The safe import checked every row against its rules, refused lines 3 and 4 with the reason, bound the values of the other three with ? placeholders, and stored Joel D'Souza and राधा कृष्णन exactly as they write their names.

Write the rule for names with care. Python’s \w does not match the vowel signs of Devanagari or Tamil, so a regular expression built from it refuses most Hindi and Tamil names, as the last line of the output shows. The program’s is_name() checks each character with str.isalpha() and accepts the combining marks that letters carry.

Validate early, and keep the reason

Check an uploaded file before any of it is stored: its size, its header, each field against its rule. Refuse a row with its line and the reason, and never repair one silently, so the sender can fix the source.

Checkpoint

Check: Validating imported rows

  1. Question 1 of 2 What does an allowlist rule for a CSV field say?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Which forms the field may have, with a length limit; everything else is refused

    An allowlist describes good input. Meena's five-digit mobile number and Ravi's 12e3 failed their rules and were refused with their lines.

  2. Question 2 of 2 Which statement stores the name Joel D'Souza exactly, whatever the name contains?

    Read the code, then choose one answer.

    name, mobile, paise = "Joel D'Souza", "9812345678", 120050
    Show the answer: Question 2 of 2

    Answer: db.execute("INSERT INTO payment VALUES (?, ?, ?)", (name, mobile, paise))

    A parameterised query keeps data out of the SQL text. Python's sqlite3 module takes ? placeholders with a tuple, or :name placeholders with a dict.

Practise: two exports from one set of rows

The exercise builds both files of this lesson: a spreadsheet export with a byte-order mark and every risky first character neutralised, full-width forms included, which refuses text in a declared number column, and a program export that keeps every value exactly:

Exercise · Medium · Python

One export for spreadsheets and one for programs

Write two functions in exports.py. Both take rows, a list of dicts with the same keys in the same order, and return the text of a CSV file, written with csv.writer (RFC 4180 quoting and CR LF), with the keys of the first row as the header.

  • to_spreadsheet_csv(rows, numeric) is for people. It starts with a byte-order mark, "\ufeff". A text value that starts with =, +, -, @, a tab, CR or LF, or with a full-width =, +, - or @, gets an apostrophe in front of it. The columns named in numeric hold numbers (int or Decimal) and are written as they are, never with an apostrophe; any other value in such a column raises ValueError whose message names the row and the column, as row 2, amount: not a number, counting the first record as row 1.
  • to_machine_csv(rows) is for programs: no byte-order mark, and every value written exactly as str() gives it, with None as an empty field.

Starter code · exports.py

import csv
import io
from decimal import Decimal


def to_spreadsheet_csv(rows, numeric):
    """CSV text for people who open it in a spreadsheet: a BOM, and formulas neutralised."""
    return ",".join(rows[0]) + "\r\n" + "".join(",".join(str(v) for v in r.values()) + "\r\n" for r in rows)


def to_machine_csv(rows):
    """CSV text for programs: every value exactly as it is."""
    return ",".join(rows[0]) + "\r\n" + "".join(",".join(str(v) for v in r.values()) + "\r\n" for r in rows)
The sample tests · test_exports.py
import csv
import io
from decimal import Decimal

from exports import to_machine_csv, to_spreadsheet_csv

ROWS = [
    {"id": "R-1", "note": "=2+5", "amount": Decimal("-250.00")},
    {"id": "R-2", "note": "Call back,+91 98765 43210", "amount": 1200},
    {"id": "R-3", "note": "@mention", "amount": Decimal("0.00")},
]


def cells(text):
    """The records of a CSV text, without its BOM."""
    return list(csv.reader(io.StringIO(text.removeprefix("\ufeff"), newline="")))


def test_spreadsheet_neutralises_formulas():
    """puts an apostrophe before text that starts like a formula"""
    rows = cells(to_spreadsheet_csv(ROWS, {"amount"}))
    assert rows[0] == ["id", "note", "amount"]
    assert [r[1] for r in rows[1:]] == ["'=2+5", "Call back,+91 98765 43210", "'@mention"]


def test_every_risky_first_character():
    """neutralises all eleven first characters, full-width ones too"""
    risky = ["=a", "+a", "-a", "@a", "\ta", "\ra", "\na", "=a", "+a", "-a", "@a"]
    rows = [{"note": v} for v in risky + ["a=b", "safe"]]
    notes = [r[0] for r in cells(to_spreadsheet_csv(rows, set()))[1:]]
    assert notes == ["'" + v for v in risky] + ["a=b", "safe"]


def test_numbers_stay_numbers():
    """writes declared numbers as they are, negative ones too"""
    rows = cells(to_spreadsheet_csv(ROWS, {"amount"}))
    assert [r[2] for r in rows[1:]] == ["-250.00", "1200", "0.00"]


def test_bom_only_for_spreadsheets():
    """starts the spreadsheet file with a BOM and the machine file without one"""
    assert to_spreadsheet_csv(ROWS, {"amount"}).startswith("\ufeffid,note,amount\r\n")
    assert to_machine_csv(ROWS).startswith("id,note,amount\r\n")


def test_machine_export_unchanged():
    """keeps every value of the machine file exactly"""
    text = to_machine_csv(ROWS + [{"id": "R-4", "note": None, "amount": -5}])
    assert cells(text)[1:] == [["R-1", "=2+5", "-250.00"], ["R-2", "Call back,+91 98765 43210", "1200"],
                               ["R-3", "@mention", "0.00"], ["R-4", "", "-5"]]
    assert '"Call back,+91 98765 43210"' in text


def test_text_in_a_numeric_column():
    """refuses text in a numeric column, naming the row and the column"""
    rows = [{"id": "R-1", "amount": 5}, {"id": "R-2", "amount": "-5+3"}]
    try:
        to_spreadsheet_csv(rows, {"amount"})
    except ValueError as e:
        assert str(e) == "row 2, amount: not a number"
    else:
        assert False, "no ValueError"
A hint

Write one helper that takes a column name and a value and returns the cell for the spreadsheet file: a number for a numeric column (checked with isinstance(value, (int, Decimal)), which also accepts True, so test for bool first), and for text the value itself, or the value with ' in front when value.startswith(RISKY) is true for a tuple RISKY of the eleven first characters.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

CSV to Excel Converter For people who need a real workbook: it writes text that starts with = as text, never as a formula.

Then answer the questions on how formulas get in, how the fix works, the two exports and safe imports. They come one at a time, beginning where your answers so far are weakest, and each explanation names the slip behind a wrong one:

Practice that adapts to you

Practise this lesson

12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 What is formula injection?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: Outside text written into a CSV cell unchanged, which a spreadsheet then runs as a formula

    Formula injection, or CSV injection, comes from exporting untrusted text into a format that a spreadsheet interprets. The fix is in the export.

  2. Question 2 of 12 An export neutralises cells that start with =, +, - and @ only. Which subject still reaches the spreadsheet unprotected?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: =SUM(1,2), with a full-width equals sign

    The first-character list has eleven entries: =, +, -, @, tab, CR and LF, and the full-width =, +, - and @. A shorter list leaves a gap, and a tab or a line break at the start is another.

  3. Question 3 of 12 A team wants to stop formula injection by refusing = at the start of every ticket subject in the web form. What is wrong with that plan?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Valid text such as +91 98765 43210 or -damaged box is refused, while data from other sources still reaches the export

    Validate input for its own rules, but neutralise formulas where the risk is: in the file written for spreadsheets. The text stays as the customer wrote it everywhere else.

  4. Question 4 of 12 What does the safe export write for the subject =2+5?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: '=2+5, with an apostrophe in front

    The export output showed T-101,Asha Iyer,'=2+5,-250. The apostrophe makes the spreadsheet show the text =2+5.

  5. Question 5 of 12 Why does the check read the finished file with csv.reader instead of checking the values before they are written?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Because the file is what the spreadsheet will read, so only the file shows cells that a split or a missing quote created

    A test of the output catches mistakes in both the values and the writing. It failed on the weak export in eight places and passed on the safe one.

  6. Question 6 of 12 A colleague opens the safe export, edits a cell, saves the file in Excel and sends it to the billing import. What should you expect?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: The protection may be gone, because OWASP warns that a save and reopen in Excel can strip the quoting and escaping

    No escaping is safe in every program and through every save. Keep the safe file for reading, and send programs the unchanged export straight from the source.

  7. Question 7 of 12 In two_exports.py, which file read back equal to the rows it came from?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: The file for programs

    The output said for programs, round trip equal: True, and listed the two notes the spreadsheet file changed.

  8. Question 8 of 12 Finance says their import now reads refunds such as -250.00 as text. What most likely went wrong?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: The refund column was neutralised like text, because it was not declared numeric in the spreadsheet export

    Declare every numeric column, write it from typed values, and point programs at the unchanged export. The exercise refuses text in a declared numeric column for the same reason.

  9. Question 9 of 12 You must also send the same data to a partner who loads it into their database. What do you send them?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: The unchanged export, or JSON Lines, with the columns, types, encoding and line ending documented

    The partner's database is a program reader. Send the exact export with its description, and keep the neutralised file for people who read data in a spreadsheet.

  10. Question 10 of 12 What did the weak import do with the row Joel D'Souza?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: It refused it with near "Souza": syntax error, because the apostrophe ended the SQL string

    A real customer was refused and two invalid rows were stored: the weak import got both decisions wrong.

  11. Question 11 of 12 Put the steps of a safe import in order.

    Give each item its position, from 1 (first).

    Show the answer: Question 11 of 12

    Answer:

    1. Check the file's size and header
    2. Check every field against its allowlist rule
    3. Refuse the failing rows with their line and reason
    4. Insert the other rows with placeholders

    Check what you can before reading on, validate each row before storing it, report refusals, and only then bind the values of the rows that passed.

  12. Question 12 of 12 A rule for names allows letters of any script with their combining marks, and spaces, apostrophes, dots and hyphens between them, up to 80 characters. Which name does it refuse?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: =2+5

    An allowlist written for real names accepts every script and the punctuation names use, and refuses text that is not a name at all, such as something that starts with =. Test it with names in the scripts your users write.

Interview questions

Warm-up (fresher to 2–5 years): what is formula injection, also called CSV injection, and who is at risk?

It is a weakness of programs that export data to CSV. A value that came from outside, such as a name or a comment, is written into a cell unchanged, and when someone opens the file in a spreadsheet, a cell that starts with =, +, - or @, or with a tab, a line break or a full-width form of those signs, is read as a formula and run. The person at risk is the one who opens the file, often a colleague who trusts it because it came from your own system, not the program that wrote it. Even simple arithmetic changes the data on screen. The fix belongs in the export: write the file with a real CSV writer, put an apostrophe in front of every text cell that starts like a formula, keep declared numbers as numbers, and give programs a separate, unchanged export.

Two harder questions follow, about a security report on a support export and about a safe export that broke a finance import. Decide what you would check first, then open each first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A security report says that a ticket subject starting with = runs as a formula when staff open your support tool's CSV export. What do you change in the export, and how do you prove that the fix works?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    After you neutralised formulas in the refunds export, finance says their reconciliation broke: negative refunds now start with an apostrophe and their import reads them as text. How do you serve both the people who open the file and finance's import?

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Which first characters make a spreadsheet read a cell as a formula?

    =, +, - and @, their full-width forms, and a tab, CR or LF at the very start of the cell.

  2. Who is at risk from formula injection in a CSV export?

    Whoever opens the file in a spreadsheet, often a colleague who trusts it because it came from your own system, not the program that wrote it.

  3. How do you neutralise a risky cell in a spreadsheet export?

    Put an apostrophe in front of any text value that starts like a formula, and let a real CSV writer quote every field. Declared number columns stay numbers.

  4. Why offer two exports of the same rows?

    The neutralised file is for people. Programs get a second file with every value unchanged, in UTF-8 with no BOM, and the documentation says which file serves whom.

  5. How should an uploaded CSV file reach a database?

    Check each field against an allowlist with a length limit, refuse bad rows with their line and the reason, and insert the rest with placeholders, never with SQL built from the text.

Key takeaways

  • Spreadsheets treat text beginning with =, +, - or @, with their full-width forms, or with a tab, CR or LF, as a formula, so an export that copies outside text unchanged lets a stranger’s formula run for whoever opens the file.
  • Write the file with a real CSV writer, so commas stay inside their quotes, then put an apostrophe in front of every text cell that starts like a formula; declared numbers are written as numbers, and a check over the finished file proves it.
  • Neutralised text is for people: give programs a second export with every value unchanged, UTF-8 without a byte-order mark, and document which file is for whom.
  • Treat uploaded CSV as untrusted input: check every field against an allowlist with a length limit, refuse rows with their line and reason, and load the rest with placeholders, never with SQL built from the text.

Terms in this lesson

formula injection
A weakness of programs that export outside text to CSV: a cell that starts with =, +, - or @, or a tab or a line break, is run as a formula by the spreadsheet that opens the file. Also called CSV injection; an export prevents it by neutralising such cells.
parameterised query
An SQL statement whose values are passed apart from its text, through placeholders such as ? or :name, so a value is never read as part of the SQL. Python's sqlite3 module calls this parameter substitution.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.