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:
@@ -28,6 +28,7 @@ from src.backend.models.api.measurement import (
|
|||||||
)
|
)
|
||||||
from src.backend.services import auth_service, measurement_service
|
from src.backend.services import auth_service, measurement_service
|
||||||
from src.backend.services.measurement_service import save_measurement
|
from src.backend.services.measurement_service import save_measurement
|
||||||
|
from src.backend.services.production_export_service import neutralise_csv_cell
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||||
|
|
||||||
@@ -342,8 +343,8 @@ async def export_measurements_csv(
|
|||||||
value_str,
|
value_str,
|
||||||
m.pass_fail,
|
m.pass_fail,
|
||||||
deviation_str,
|
deviation_str,
|
||||||
m.lot_number or "",
|
neutralise_csv_cell(m.lot_number or ""),
|
||||||
m.serial_number or "",
|
neutralise_csv_cell(m.serial_number or ""),
|
||||||
m.input_method,
|
m.input_method,
|
||||||
m.measured_at.isoformat(),
|
m.measured_at.isoformat(),
|
||||||
m.synced_to_csv,
|
m.synced_to_csv,
|
||||||
|
|||||||
@@ -38,6 +38,26 @@ EXPORT_SUBDIR = "statistics"
|
|||||||
_VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
|
_VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
|
||||||
_FALLBACK_CSV_DELIMITER = ";"
|
_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 = [
|
HEADER = [
|
||||||
"production_run_id",
|
"production_run_id",
|
||||||
"station_code",
|
"station_code",
|
||||||
@@ -127,15 +147,15 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
|||||||
for measurement, subtask in rows:
|
for measurement, subtask in rows:
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
run.id,
|
run.id,
|
||||||
station.code if station else "",
|
neutralise_csv_cell(station.code) if station else "",
|
||||||
recipe.code if recipe else "",
|
neutralise_csv_cell(recipe.code) if recipe else "",
|
||||||
measurement.version_id,
|
measurement.version_id,
|
||||||
measurement.lot_number or "",
|
neutralise_csv_cell(measurement.lot_number or ""),
|
||||||
measurement.serial_number or "",
|
neutralise_csv_cell(measurement.serial_number or ""),
|
||||||
measurement.id,
|
measurement.id,
|
||||||
measurement.subtask_id,
|
measurement.subtask_id,
|
||||||
subtask.marker_number if subtask else "",
|
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(subtask.nominal) if subtask else "",
|
||||||
number(measurement.value),
|
number(measurement.value),
|
||||||
number(measurement.deviation),
|
number(measurement.deviation),
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
"""Tests for measurements router (/api/measurements)."""
|
"""Tests for measurements router (/api/measurements)."""
|
||||||
|
import csv
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||||
|
from src.backend.services import auth_service
|
||||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
@@ -223,6 +227,281 @@ class TestMeasurementPassFail:
|
|||||||
assert resp.json()["pass_fail"] == "fail"
|
assert resp.json()["pass_fail"] == "fail"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMeasurementValueBounds:
|
||||||
|
"""value rejects NaN/Infinity and out-of-DECIMAL(12,6)-scale numbers (#20)."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_value", [1e300, 1_000_000])
|
||||||
|
async def test_out_of_scale_value_is_422(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
measurement_tec_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
bad_value: float,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": bad_value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw_token", ["NaN", "Infinity", "-Infinity"])
|
||||||
|
async def test_nan_and_infinity_are_422(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
measurement_tec_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
raw_token: str,
|
||||||
|
):
|
||||||
|
"""NaN/Infinity are not valid JSON: httpx's own encoder refuses to send
|
||||||
|
them via ``json=``, so the raw body is built by hand here - exactly
|
||||||
|
what a client that is not httpx could still put on the wire."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
body = (
|
||||||
|
'{"subtask_id": %d, "version_id": %d, "value": %s}'
|
||||||
|
% (subtask_id, version_id, raw_token)
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers={**auth_headers(measurement_tec_user), "Content-Type": "application/json"},
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_value_is_rounded_before_the_verdict(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
measurement_tec_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""10.5000004 rounds to 10.5 (=utl) first, so it is not a fail."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": 10.5000004,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["value"] == pytest.approx(10.5)
|
||||||
|
# 10.5 is > uwl(10.3) but not > utl(10.5): warning, not fail.
|
||||||
|
assert data["pass_fail"] == "warning"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveMeasurementCoherence:
|
||||||
|
"""save_measurement checks quote/version/run coherence before insert (#5)."""
|
||||||
|
|
||||||
|
async def _station(self, db_session, user_id, code):
|
||||||
|
station = Station(
|
||||||
|
code=code, name=f"Stazione {code}", active=True, created_by=user_id,
|
||||||
|
)
|
||||||
|
db_session.add(station)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(station)
|
||||||
|
return station
|
||||||
|
|
||||||
|
async def _supervisor(self, db_session, username):
|
||||||
|
user = await auth_service.create_user(
|
||||||
|
db_session, username=username, password="TurnoPwd1",
|
||||||
|
display_name="Capo Turno", roles=["Supervisor"],
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
return user, "TurnoPwd1"
|
||||||
|
|
||||||
|
async def test_quota_of_another_recipe_version_is_422(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe_a = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-A")
|
||||||
|
recipe_b = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-B")
|
||||||
|
subtask_a, _version_a = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe_a.id
|
||||||
|
)
|
||||||
|
_subtask_b, version_b = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe_b.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"subtask_id": subtask_a, "version_id": version_b, "value": 10.0},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_measurement_of_other_recipe_in_run_is_422(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe_a = await create_test_recipe(
|
||||||
|
db_session, measurement_tec_user.id, code="COH-RUNA"
|
||||||
|
)
|
||||||
|
recipe_b = await create_test_recipe(
|
||||||
|
db_session, measurement_tec_user.id, code="COH-RUNB"
|
||||||
|
)
|
||||||
|
station = await self._station(db_session, admin_user.id, "ST-COH1")
|
||||||
|
|
||||||
|
run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe_a.id},
|
||||||
|
)).json()
|
||||||
|
|
||||||
|
subtask_b, version_b = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe_b.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_b,
|
||||||
|
"version_id": version_b,
|
||||||
|
"value": 10.0,
|
||||||
|
"production_run_id": run["id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_measurement_on_closed_run_is_409(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-CLOSED")
|
||||||
|
station = await self._station(db_session, admin_user.id, "ST-COH2")
|
||||||
|
run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||||
|
)).json()
|
||||||
|
supervisor, password = await self._supervisor(db_session, "capo-coh-close")
|
||||||
|
await client.post(
|
||||||
|
f"/api/production-runs/{run['id']}/close",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"supervisor_username": supervisor.username,
|
||||||
|
"supervisor_password": password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": 10.0,
|
||||||
|
"production_run_id": run["id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
async def test_measurement_on_paused_run_is_409(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-PAUSED")
|
||||||
|
station = await self._station(db_session, admin_user.id, "ST-COH3")
|
||||||
|
run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||||
|
)).json()
|
||||||
|
supervisor, password = await self._supervisor(db_session, "capo-coh-pause")
|
||||||
|
await client.post(
|
||||||
|
f"/api/production-runs/{run['id']}/pause",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"supervisor_username": supervisor.username,
|
||||||
|
"supervisor_password": password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": 10.0,
|
||||||
|
"production_run_id": run["id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
async def test_valid_measurement_in_matching_run_is_200(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-OK")
|
||||||
|
station = await self._station(db_session, admin_user.id, "ST-COH4")
|
||||||
|
run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||||
|
)).json()
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": 10.0,
|
||||||
|
"production_run_id": run["id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
async def test_measurement_on_unknown_run_is_404(
|
||||||
|
self, client: AsyncClient, measurement_tec_user: User, db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-404")
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": 10.0,
|
||||||
|
"production_run_id": 999999,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestListMeasurements:
|
class TestListMeasurements:
|
||||||
"""GET /api/measurements/ tests."""
|
"""GET /api/measurements/ tests."""
|
||||||
|
|
||||||
@@ -366,3 +645,76 @@ class TestMeasurementStatistics:
|
|||||||
# deviation = 10.2 - nominal(10.0) = 0.2
|
# deviation = 10.2 - nominal(10.0) = 0.2
|
||||||
assert data["deviation"] is not None
|
assert data["deviation"] is not None
|
||||||
assert abs(data["deviation"] - 0.2) < 0.001
|
assert abs(data["deviation"] - 0.2) < 0.001
|
||||||
|
|
||||||
|
|
||||||
|
class TestExportCsv:
|
||||||
|
"""GET /api/measurements/export/csv tests (gitea#22)."""
|
||||||
|
|
||||||
|
async def test_lot_number_formula_is_neutralised(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
measurement_tec_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""A lot_number starting with '=' must not reach the CSV as a live formula (#22)."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": 10.0,
|
||||||
|
"lot_number": '=HYPERLINK("http://x","y")',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
export_resp = await client.get(
|
||||||
|
"/api/measurements/export/csv",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert export_resp.status_code == 200
|
||||||
|
rows = list(csv.reader(export_resp.text.splitlines()))
|
||||||
|
header, row = rows[0], rows[1]
|
||||||
|
lot_cell = row[header.index("lot_number")]
|
||||||
|
# Quoted with a leading ' so a spreadsheet reads it as literal text,
|
||||||
|
# not the raw formula that would otherwise be live in Excel/LibreOffice.
|
||||||
|
assert lot_cell == '\'=HYPERLINK("http://x","y")'
|
||||||
|
|
||||||
|
async def test_negative_value_stays_numeric(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
measurement_tec_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""Neutralisation must never touch numeric cells like the measured value."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": -1.25,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
export_resp = await client.get(
|
||||||
|
"/api/measurements/export/csv",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert export_resp.status_code == 200
|
||||||
|
rows = list(csv.reader(export_resp.text.splitlines()))
|
||||||
|
header, row = rows[0], rows[1]
|
||||||
|
value_cell = row[header.index("value")]
|
||||||
|
assert value_cell == "-1.250000"
|
||||||
|
|||||||
@@ -2,11 +2,46 @@
|
|||||||
|
|
||||||
- CSV format defenses (gitea#23): a dirty csv_delimiter read from the DB must
|
- CSV format defenses (gitea#23): a dirty csv_delimiter read from the DB must
|
||||||
never make close_run fail with a csv.writer TypeError.
|
never make close_run fail with a csv.writer TypeError.
|
||||||
|
- Formula-injection neutralisation (gitea#22): text cells starting with
|
||||||
|
=, +, -, @, TAB or CR must be defused before they reach a CSV cell.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.models.orm.setting import SystemSetting
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
from src.backend.services.production_export_service import _csv_format
|
from src.backend.services.production_export_service import (
|
||||||
|
_csv_format,
|
||||||
|
neutralise_csv_cell,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNeutraliseCsvCell:
|
||||||
|
"""gitea#22: formula-injection prefixes must be quoted, plain text untouched."""
|
||||||
|
|
||||||
|
def test_equals_prefix_is_quoted(self):
|
||||||
|
assert neutralise_csv_cell('=HYPERLINK("http://x","y")') == \
|
||||||
|
'\'=HYPERLINK("http://x","y")'
|
||||||
|
|
||||||
|
def test_plus_minus_at_prefixes_are_quoted(self):
|
||||||
|
assert neutralise_csv_cell("+1;2").startswith("'")
|
||||||
|
assert neutralise_csv_cell("-cmd|calc").startswith("'")
|
||||||
|
assert neutralise_csv_cell("@SUM(A1)").startswith("'")
|
||||||
|
|
||||||
|
def test_tab_and_cr_prefixes_are_quoted(self):
|
||||||
|
assert neutralise_csv_cell("\t=1+1").startswith("'")
|
||||||
|
assert neutralise_csv_cell("\r=1+1").startswith("'")
|
||||||
|
|
||||||
|
def test_leading_spaces_before_dangerous_prefix_are_quoted(self):
|
||||||
|
assert neutralise_csv_cell(" =cmd").startswith("'")
|
||||||
|
|
||||||
|
def test_plain_text_is_untouched(self):
|
||||||
|
assert neutralise_csv_cell("LOT-2026-001") == "LOT-2026-001"
|
||||||
|
assert neutralise_csv_cell("") == ""
|
||||||
|
|
||||||
|
def test_negative_number_as_text_is_still_quoted(self):
|
||||||
|
"""The function only sees text cells; callers must not pass numeric
|
||||||
|
cells to it (that responsibility is the caller's, see -1.25 test in
|
||||||
|
test_measurements.py). Given a string, a leading '-' is neutralised."""
|
||||||
|
assert neutralise_csv_cell("-1.25").startswith("'")
|
||||||
|
|
||||||
|
|
||||||
class TestCsvFormatDefense:
|
class TestCsvFormatDefense:
|
||||||
|
|||||||
Reference in New Issue
Block a user