Skip to content

Commit d1cb604

Browse files
Gheat1hamidi-dev
authored andcommitted
fix(export): neutralize formula-prefixed cells in csv export
A session title, project dir, or model name starting with =, +, -, @, tab, or CR is executed as a formula when the exported CSV is opened in Excel/LibreOffice/Sheets. Prefix such strings with an apostrophe on export; strings that are plain numbers (a negative cost) pass through untouched, and non-string cells are never affected.
1 parent f2144a1 commit d1cb604

2 files changed

Lines changed: 57 additions & 1 deletion

File tree

src/opentab/tui/app.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1872,6 +1872,21 @@ def _tools_dataset(self, session: Workflow) -> tuple[str, list[str], list[list]]
18721872
)
18731873
return "tools", header, rows
18741874

1875+
@staticmethod
1876+
def _csv_safe(value):
1877+
# Neutralize spreadsheet formula injection: a cell starting with =, +, -,
1878+
# @, tab, or CR is executed as a formula by Excel/LibreOffice/Sheets, and
1879+
# titles/dirs/models are attacker-influenced text. Only strings need the
1880+
# guard, and a string that is itself a plain number (a negative cost)
1881+
# passes through -- only would-be formulas get the leading apostrophe.
1882+
if not isinstance(value, str) or not value or value[0] not in "=+-@\t\r":
1883+
return value
1884+
try:
1885+
float(value)
1886+
return value
1887+
except ValueError:
1888+
return "'" + value
1889+
18751890
def export_current(self) -> None:
18761891
if self.store.demo:
18771892
self.notify("export disabled in demo mode", "error")
@@ -1886,7 +1901,7 @@ def export_current(self) -> None:
18861901
with open(path, "w", newline="") as fh:
18871902
writer = csv.writer(fh)
18881903
writer.writerow(header)
1889-
writer.writerows(rows)
1904+
writer.writerows([[self._csv_safe(cell) for cell in row] for row in rows])
18901905
except OSError as exc:
18911906
self.notify(f"export failed: {exc}", "error")
18921907
return

test_opentab.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3866,6 +3866,47 @@ def test_export_sources_tab_exports_the_source_breakdown():
38663866
assert rows[0][0] == "Claude Code" and rows[0][1] == 5 # cost-sorted, priciest first
38673867

38683868

3869+
def test_export_neutralizes_formula_prefixed_cells():
3870+
# Formula injection: a cell starting with =, +, -, @, tab, or CR is executed
3871+
# by Excel/LibreOffice/Sheets on import. Would-be formulas get a leading
3872+
# apostrophe; plain numbers (negative included) and non-strings pass through.
3873+
safe = ot.App._csv_safe
3874+
assert safe("=SUM(A1:A9)") == "'=SUM(A1:A9)"
3875+
assert safe("+cmd|' /C calc'!A0") == "'+cmd|' /C calc'!A0"
3876+
assert safe("@evil") == "'@evil"
3877+
assert safe("-rm -rf notes") == "'-rm -rf notes"
3878+
assert safe("\t=1+1") == "'\t=1+1"
3879+
assert safe("\r=1+1") == "'\r=1+1"
3880+
assert safe("-1.5") == "-1.5" # a negative number string is not a formula
3881+
assert safe("+42") == "+42"
3882+
assert safe(-1.5) == -1.5 and safe(0) == 0 # non-strings untouched
3883+
assert safe("session title") == "session title"
3884+
assert safe("") == ""
3885+
3886+
3887+
def test_export_current_sanitizes_the_written_csv():
3888+
import csv
3889+
import tempfile
3890+
3891+
w = workflow("w1", "2026-06-01 12:00:00", title='=HYPERLINK("http://x","y")')
3892+
app = app_with([w])
3893+
app.view = "zoom"
3894+
app.focus = "months"
3895+
app.tab = app.month_tabs.index("Sessions")
3896+
cwd = os.getcwd()
3897+
os.chdir(tempfile.mkdtemp(prefix="ot-export-"))
3898+
try:
3899+
app.export_current()
3900+
assert "exported" in app.notice
3901+
(name,) = (f for f in os.listdir(".") if f.startswith("opentab-sessions-"))
3902+
with open(name, newline="") as fh:
3903+
header, row = list(csv.reader(fh))
3904+
finally:
3905+
os.chdir(cwd)
3906+
assert row[header.index("title")] == '\'=HYPERLINK("http://x","y")'
3907+
assert row[header.index("total_cost")] == "1.0" # numeric cells stay numbers
3908+
3909+
38693910
def test_export_session_tabs_dispatch_to_their_tables():
38703911
# A store rich enough to back the Subagents / Turns / Tools tabs.
38713912
class RichStore(FakeStore):

0 commit comments

Comments
 (0)