CRM CSV Exports: Quarantine Suspicious Fields Before Handoff
A CRM export policy for untrusted text: separate consumers, hold suspicious rows, and test decisions with synthetic records without opening a spreadsheet.
Why a CRM CSV export needs its own check
Separate machine transfer from spreadsheet viewing before handing an export to a sales representative. User-entered fields can become formulas in the application that opens the file. Under an agreed policy, hold suspicious rows for review while retaining their original values in protected storage. Putting fields inside quotation marks does not establish that formulas are safe.
OWASP describes CSV Injection as untrusted input entering a CSV and subsequently being interpreted by spreadsheet software. In particular, a cell beginning with = may be treated as a formula. CWE-1236 documents the weakness and the limitations of mitigation techniques.[1][2]
A company name, an enquiry comment, or a field copied from another service can contain untrusted text. Passing through a trusted CRM does not change its origin. Permission to download a report does not establish that every value is safe for the next application.
This article proposes a policy for an export handler you control. It does not describe a verified built-in 24go.site feature, a universal defense for every spreadsheet, or permission to collect other people's contacts. The example uses synthetic records only. It checks handler decisions, does not launch Excel or Calc, and makes no CRM requests.
Agree on who will open the file
An export needs a named consumer. A machine-transfer file must preserve values according to an explicit data contract. A file intended for human viewing needs application-specific behavior checks. Button labels, format descriptions, and storage routes should make that distinction visible.
| Purpose | Agree on | Treat as an error |
|---|---|---|
| Machine transfer | Schema, encoding, delimiter, empty-value handling | Silently changing a phone number or comment |
| Sales representative's review | Application, version, import settings, exception policy | Interpreting untrusted text as a formula |
| Exception investigation | Owner, access, retention, viewing method | Moving the original suspect values into an ordinary CSV |
Start a human-readable report with the smallest useful set of columns. If the recipient needs only an enquiry ID, status, and date, omit the full conversation. Reducing fields reduces the amount of untrusted text and disclosed information; it does not replace validation of the values that remain.
The handoff sits alongside Telegram-to-CRM integration, but export checking addresses a separate task after a record has been stored. The policy here does not verify the product settings or capabilities mentioned in historical blog articles.
Keep CSV serialization separate from formula policy
A CSV library handles field and row boundaries. Your content policy decides which values may be handed to a particular consumer. Run these checks in sequence. Reading a file back successfully with the same library verifies the transfer format, not spreadsheet formula behavior.
Python's documentation describes csv.writer, delimiter and quoting options, and opening CSV files with newline=''. By default, csv.reader returns strings without automatic type conversion. That is Python parser behavior, not a promise about how a spreadsheet will open the same file.[3]
Do not construct a row using ','.join(...). Commas, quotation marks, and line breaks inside a comment must remain inside that field. The example uses csv.writer to quote every field, then csv.reader to check the round trip. It applies the suspicious-prefix policy before writing; the QUOTE_ALL option does not replace that policy.
OWASP lists risky beginnings including =, +, -, @, tab, carriage return, line feed, and some full-width character variants. It also warns that delimiters and quotes can create a new cell boundary when handled incorrectly.[1] This gives you test cases, not evidence that a particular detector covers every interpretation.
Do not remove the first character from the original CRM record. A plus can belong to a phone number, a minus can be meaningful text, and an apostrophe can belong to a name. If you choose a viewing transformation, retain the original separately and document what the recipient will see. Apply the change in the presentation layer and test it in the target application.
What whole-row quarantine means
Under this proposed policy, a row containing a suspicious field is withheld from the human-view CSV. A separate exception log receives its ID, column name, and reason code. The original value stays in the system under restricted access. The handler does not automatically repair the value or delete the enquiry.
This is deliberately conservative. A phone number beginning with a plus can hold up an export if you include it among the text columns checked by this rule. That is an expected false positive of the policy. Phone numbers, amounts, and other typed fields need their own agreed contract, not an undocumented exemption.
| Situation | Example's decision | Next action |
|---|---|---|
| Ordinary text | Include the row | Check CSV structure |
| Formula-like prefix after leading spaces | Hold the entire row | Review the field through a protected interface |
| Tab or line break anywhere in the field | Hold the entire row | Confirm whether multiline text is required |
| Unknown status or invalid schema | Stop processing with an error | Repair the contract; do not issue a successful file |
| Comma and quotes inside ordinary text | Serialize with the CSV library | Compare the value after reading it back |
The exception log must not become another route for the same problem. Do not put the original suspect comment in a second CSV or paste the entire value into a notification. The example's JSON log contains only a synthetic ID, field name, and reason. Inspecting originals requires an interface that displays them as text without interpreting markup or formulas.
Reproduce the policy check without a spreadsheet
Save the following code as csv_policy_check.py and run python3 csv_policy_check.py. It requires Python 3 and its standard library. It creates records in memory, checks included rows and the exception log, writes no export file, and opens no spreadsheet. The value =1+1 is a harmless string used to exercise the rule; no calculation is performed.
import csv
import io
import json
import unicodedata
PREFIXES = set("=+-@=+-@")
FIELDS = ("id", "status", "note")
STATUSES = {"new", "qualified"}
def reason(value):
if any(ch in value for ch in "\t\r\n"):
return "control_separator"
probe = value.lstrip()
if probe and unicodedata.category(probe[0]) == "Cf":
return "leading_format_character"
if probe and probe[0] in PREFIXES:
return "formula_prefix"
return None
def export_for_review(rows):
accepted = []
held = []
for row in rows:
if set(row) != set(FIELDS):
raise ValueError("schema")
if not all(isinstance(row[k], str) for k in FIELDS):
raise ValueError("types")
if not row["id"].isascii() or not row["id"].isdigit():
raise ValueError("id")
if row["status"] not in STATUSES:
raise ValueError("status")
issue = reason(row["note"])
if issue:
held.append({"id": row["id"], "field": "note", "reason": issue})
else:
accepted.append(row)
out = io.StringIO(newline="")
writer = csv.DictWriter(out, fieldnames=FIELDS, quoting=csv.QUOTE_ALL)
writer.writeheader()
writer.writerows(accepted)
result = out.getvalue()
restored = list(csv.DictReader(io.StringIO(result, newline="")))
assert restored == accepted
return result, held
rows = [
{"id": "1", "status": "new", "note": "Call tomorrow"},
{"id": "2", "status": "new", "note": '=1+1'},
{"id": "3", "status": "qualified", "note": 'Said "yes", call later'},
{"id": "4", "status": "new", "note": " +44123"},
{"id": "5", "status": "new", "note": "line\nbreak"},
{"id": "6", "status": "new", "note": "=1+1"},
{"id": "7", "status": "new", "note": "\u200b=1+1"},
]
text, held = export_for_review(rows)
assert [r["id"] for r in csv.DictReader(io.StringIO(text))] == ["1", "3"]
assert [r["id"] for r in held] == ["2", "4", "5", "6", "7"]
for value in ("=1+1", "+1", "-1", "@name", "=1", "+1", "-1", "@name"):
assert reason(value) == "formula_prefix"
assert reason(" =1+1") == "formula_prefix"
assert reason("\tplain") == "control_separator"
assert reason("plain\rtext") == "control_separator"
for bad in (
{"id": "8", "status": "unknown", "note": "plain"},
{"id": "x", "status": "new", "note": "plain"},
{"id": "8", "status": "new", "note": None},
):
try:
export_for_review([bad])
except ValueError:
pass
else:
raise AssertionError("invalid row accepted")
print(json.dumps({"accepted_ids": ["1", "3"], "held": held}, sort_keys=True))
Expect included IDs 1 and 3; the other synthetic records should appear in the held list with reason codes. The assertions also require rejection of an unknown status, a nonnumeric ID, and None. A traceback or AssertionError means the check failed; do not count that run as evidence that the policy works.
The detector intentionally holds any line break in a note and a leading Unicode format character in category Cf after whitespace. These are additional restrictions proposed by this example. They are not CSV requirements or a complete model of Unicode behavior in spreadsheets. If you add columns, extend validation to every new untrusted field.
What the Python check cannot establish
The round trip establishes that the two permitted synthetic records survive this CSV dialect unchanged. It does not establish identical handling across every Excel version, Calc, or another application. OWASP explicitly warns that no sanitization strategy is universally safe for every spreadsheet and downstream consumer.[1]
Before deployment, record the application, its version, regional settings, and import route. In an isolated environment using synthetic data, check the displayed value, cell type, column boundaries, and the absence of calculation for values intended to remain text. Do not use network formulas, commands, or real personal information for these checks.
Then save and reopen the file through the same route the recipient will use. OWASP notes that Excel can remove quotes or escape characters during saving and reopening.[1] A first-open check is therefore insufficient for a policy that relies on cell transformations. If the required application is unavailable, that stage remains unverified rather than automatically passing.
Quarantine also needs an operational check: the recipient sees the exceptions count, the owner can find the original enquiry, and a later export neither loses it nor silently removes its restriction. Changing an enquiry's business status must not automatically declare its comment safe.
Introduce the policy without losing enquiries
Start with observation in a test environment. Agree on hold reasons with the process owner and establish that records remain in the original system. Decide whether to enable production blocking based on concrete tests and team requirements, not on the existence of one successful script.
- Define the columns and permitted types, including separate rules for empty strings, missing values, and negative numbers.
- Assign a consumer to each export and prevent silent switching between human viewing and machine import.
- Retain inclusion and hold examples covering commas, quotes, spaces, control characters, and full-width characters.
- Check that a schema violation fails the whole job. Do not distribute a partly written file as a successful result.
- Agree on protected exception review and the route for exporting a record after a human decision.
- After changing a library, target application, or column set, rerun the affected checks and retain execution conditions.
Start with one existing report: list its untrusted fields, identify the actual recipient, and agree on what happens when the first row is held. If those answers are missing, the export contract is not ready. Consider 24go.site for enquiry workflows separately from this check: this article does not establish that the service implements the export mechanism described here.
Sources
[1] OWASP, CSV Injection: https://owasp.org/www-community/attacks/CSV_Injection
[2] MITRE, CWE-1236: https://cwe.mitre.org/data/definitions/1236.html
[3] Python, csv: https://docs.python.org/3/library/csv.html