fix(csv): neutralizza injection di formule nei campi testuali export (#22)

lot_number, serial_number (export manuale e di produzione), station.code,
recipe.code, subtask.description finivano nel CSV cosi' come arrivavano:
un lotto '=HYPERLINK(...)' apriva un link vivo in Excel/LibreOffice. Nuova
neutralise_csv_cell() in production_export_service.py, importata da
measurements.py: se il campo (spazi iniziali esclusi) comincia con =, +, -,
@, TAB o CR antepone un apice, cosi' resta testo letterale. Applicata solo
ai campi testuali, mai ai numeri.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:33:32 +02:00
parent d1833ba203
commit 0dce2299d0
4 changed files with 416 additions and 8 deletions
@@ -38,6 +38,26 @@ EXPORT_SUBDIR = "statistics"
_VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
_FALLBACK_CSV_DELIMITER = ";"
# A cell starting with one of these (after leading spaces) is read as a formula
# by Excel/LibreOffice when the CSV is opened — CSV/formula injection (OWASP).
_DANGEROUS_CSV_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
def neutralise_csv_cell(value: str) -> str:
"""Defuse a formula-injection payload in a text CSV cell.
Prepends a single quote when the value, stripped of leading spaces, starts
with =, +, -, @, TAB or CR: this keeps spreadsheet software from evaluating
it as a formula while the value stays valid, literal CSV text. Only for
free-text fields (lot, serial, descriptions, names, codes) — never call
this on numeric cells, the quote would make them text.
"""
stripped = value.lstrip(" ")
if stripped and stripped[0] in _DANGEROUS_CSV_PREFIXES:
return "'" + value
return value
HEADER = [
"production_run_id",
"station_code",
@@ -127,15 +147,15 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
for measurement, subtask in rows:
writer.writerow([
run.id,
station.code if station else "",
recipe.code if recipe else "",
neutralise_csv_cell(station.code) if station else "",
neutralise_csv_cell(recipe.code) if recipe else "",
measurement.version_id,
measurement.lot_number or "",
measurement.serial_number or "",
neutralise_csv_cell(measurement.lot_number or ""),
neutralise_csv_cell(measurement.serial_number or ""),
measurement.id,
measurement.subtask_id,
subtask.marker_number if subtask else "",
subtask.description if subtask else "",
neutralise_csv_cell(subtask.description) if subtask else "",
number(subtask.nominal) if subtask else "",
number(measurement.value),
number(measurement.deviation),