Skip to content

Commit a5f8acc

Browse files
committed
Quote all exported CSV data
Quote all non-numeric data in csv export functions. Report that a title like '=a2+b3' could be interpreted as a function in Excel and executed. csv.writer now includes quoting=csv.QUOTE_NONNUMERIC to generate quoted values for all fields. This should make the string starting with = be interpreted as a string and not a formula.
1 parent 2b716e9 commit a5f8acc

File tree

4 files changed

+67
-30
lines changed

4 files changed

+67
-30
lines changed

CHANGES.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ Fixed:
1919
Meerwald)
2020
- exception in logout action when there is no session (Christof
2121
Meerwald)
22+
- quote all non-numeric data in csv export functions. Report that a
23+
title like '=a2+b3' could be interpreted as a function in Excel and
24+
executed. csv.writer now includes quoting=csv.QUOTE_NONNUMERIC to
25+
generate quoted values for all fields. This makes the string
26+
starting with = be interpreted as a string and not a formula. (John
27+
Rouillard as reported in the decomissioned bpo meta tracker IIRC.)
2228

2329
Features:
2430

doc/upgrading.txt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,37 @@ or::
160160

161161
if db.tx_Source in ['web', 'rest', 'xmlrpc', 'email-sig-openpgp', 'cli' ]:
162162

163+
164+
CSV export changes
165+
------------------
166+
167+
The original Roundup CSV export function for indexes reported id
168+
numbers for links. The wiki had a version that resolved the id's to
169+
names, so it would report ``open`` rather than ``2`` or
170+
``user2;user3`` rather than ``[2,3]``.
171+
172+
Many people added the enhanced version to their extensions directory.
173+
174+
The enhanced version was made the default in roundup 2.0. If you want
175+
to use the old version (that returns id's), you can replace references
176+
to ``export_csv`` with ``export_csv_id`` in templates.
177+
178+
Both core csv export functions have been changed to force quoting of
179+
all exported fields. To incorporate this change in any CSV export
180+
extension you may have added, change references in your code from::
181+
182+
writer = csv.writer(wfile)
183+
184+
to::
185+
186+
writer = csv.writer(wfile, quoting=csv.QUOTE_NONNUMERIC)
187+
188+
this forces all (non-numeric) fields to be quoted and empty quotes to
189+
be added for missing parameters.
190+
191+
This turns exported values that may look like formulas into strings so
192+
some versions of Excel won't try to interpret them as a formula.
193+
163194
Update userauditor.py to restrict usernames
164195
-------------------------------------------
165196

roundup/cgi/actions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1438,7 +1438,7 @@ def handle(self):
14381438
self.client.STORAGE_CHARSET,
14391439
self.client.charset, 'replace')
14401440

1441-
writer = csv.writer(wfile)
1441+
writer = csv.writer(wfile, quoting=csv.QUOTE_NONNUMERIC)
14421442

14431443
# handle different types of columns.
14441444
def repr_no_right(cls, col):
@@ -1603,7 +1603,7 @@ def handle(self):
16031603
self.client.STORAGE_CHARSET,
16041604
self.client.charset, 'replace')
16051605

1606-
writer = csv.writer(wfile)
1606+
writer = csv.writer(wfile, quoting=csv.QUOTE_NONNUMERIC)
16071607
self.client._socket_op(writer.writerow, columns)
16081608

16091609
# and search

test/test_cgi.py

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1792,25 +1792,25 @@ def testCSVExport(self):
17921792
cl.request.wfile = output
17931793
# call export version that outputs names
17941794
actions.ExportCSVAction(cl).handle()
1795-
#print(output.getvalue())
1796-
should_be=(s2b('id,title,status,keyword,assignedto,nosy\r\n'
1797-
'1,foo1,deferred,,"Contrary, Mary","Bork, Chef;Contrary, Mary;demo"\r\n'
1798-
'2,bar2,unread,keyword1;keyword2,"Bork, Chef","Bork, Chef"\r\n'
1799-
'3,baz32,need-eg,,,\r\n'))
1795+
should_be=(s2b('"id","title","status","keyword","assignedto","nosy"\r\n'
1796+
'"1","foo1","deferred","","Contrary, Mary","Bork, Chef;Contrary, Mary;demo"\r\n'
1797+
'"2","bar2","unread","keyword1;keyword2","Bork, Chef","Bork, Chef"\r\n'
1798+
'"3","baz32","need-eg","","",""\r\n'))
18001799
#print(should_be)
1801-
#print(output.getvalue())
1800+
print(output.getvalue())
18021801
self.assertEqual(output.getvalue(), should_be)
18031802
output = io.BytesIO()
18041803
cl.request = MockNull()
18051804
cl.request.wfile = output
18061805
# call export version that outputs id numbers
18071806
actions.ExportCSVWithIdAction(cl).handle()
1807+
should_be = s2b('"id","title","status","keyword","assignedto","nosy"\r\n'
1808+
"\"1\",\"foo1\",\"2\",\"[]\",\"4\",\"['3', '4', '5']\"\r\n"
1809+
"\"2\",\"bar2\",\"1\",\"['1', '2']\",\"3\",\"['3']\"\r\n"
1810+
'\"3\","baz32",\"4\","[]","None","[]"\r\n')
1811+
#print(should_be)
18081812
print(output.getvalue())
1809-
self.assertEqual(s2b('id,title,status,keyword,assignedto,nosy\r\n'
1810-
"1,foo1,2,[],4,\"['3', '4', '5']\"\r\n"
1811-
"2,bar2,1,\"['1', '2']\",3,['3']\r\n"
1812-
'3,baz32,4,[],None,[]\r\n'),
1813-
output.getvalue())
1813+
self.assertEqual(output.getvalue(), should_be)
18141814

18151815
def testCSVExportCharset(self):
18161816
cl = self._make_client(
@@ -1827,8 +1827,8 @@ def testCSVExportCharset(self):
18271827
cl.request.wfile = output
18281828
# call export version that outputs names
18291829
actions.ExportCSVAction(cl).handle()
1830-
should_be=(b'id,title,status,keyword,assignedto,nosy\r\n'
1831-
b'1,foo1\xc3\xa4,deferred,,"Contrary, Mary","Bork, Chef;Contrary, Mary;demo"\r\n')
1830+
should_be=(b'"id","title","status","keyword","assignedto","nosy"\r\n'
1831+
b'"1","foo1\xc3\xa4","deferred","","Contrary, Mary","Bork, Chef;Contrary, Mary;demo"\r\n')
18321832
self.assertEqual(output.getvalue(), should_be)
18331833

18341834
output = io.BytesIO()
@@ -1837,8 +1837,8 @@ def testCSVExportCharset(self):
18371837
# call export version that outputs id numbers
18381838
actions.ExportCSVWithIdAction(cl).handle()
18391839
print(output.getvalue())
1840-
self.assertEqual(b'id,title,status,keyword,assignedto,nosy\r\n'
1841-
b"1,foo1\xc3\xa4,2,[],4,\"['3', '4', '5']\"\r\n",
1840+
self.assertEqual(b'"id","title","status","keyword","assignedto","nosy"\r\n'
1841+
b"\"1\",\"foo1\xc3\xa4\",\"2\",\"[]\",\"4\",\"['3', '4', '5']\"\r\n",
18421842
output.getvalue())
18431843

18441844
# again with ISO-8859-1 client charset
@@ -1848,8 +1848,8 @@ def testCSVExportCharset(self):
18481848
cl.request.wfile = output
18491849
# call export version that outputs names
18501850
actions.ExportCSVAction(cl).handle()
1851-
should_be=(b'id,title,status,keyword,assignedto,nosy\r\n'
1852-
b'1,foo1\xe4,deferred,,"Contrary, Mary","Bork, Chef;Contrary, Mary;demo"\r\n')
1851+
should_be=(b'"id","title","status","keyword","assignedto","nosy"\r\n'
1852+
b'"1","foo1\xe4","deferred","","Contrary, Mary","Bork, Chef;Contrary, Mary;demo"\r\n')
18531853
self.assertEqual(output.getvalue(), should_be)
18541854

18551855
output = io.BytesIO()
@@ -1858,8 +1858,8 @@ def testCSVExportCharset(self):
18581858
# call export version that outputs id numbers
18591859
actions.ExportCSVWithIdAction(cl).handle()
18601860
print(output.getvalue())
1861-
self.assertEqual(b'id,title,status,keyword,assignedto,nosy\r\n'
1862-
b"1,foo1\xe4,2,[],4,\"['3', '4', '5']\"\r\n",
1861+
self.assertEqual(b'"id","title","status","keyword","assignedto","nosy"\r\n'
1862+
b"\"1\",\"foo1\xe4\",\"2\",\"[]\",\"4\",\"['3', '4', '5']\"\r\n",
18631863
output.getvalue())
18641864

18651865
def testCSVExportBadColumnName(self):
@@ -1903,12 +1903,12 @@ def testCSVExportFailPermissionValidColumn(self):
19031903

19041904
actions.ExportCSVAction(cl).handle()
19051905
#print(output.getvalue())
1906-
self.assertEqual(s2b('id,username,address,password\r\n'
1907-
'1,admin,[hidden],[hidden]\r\n'
1908-
'2,anonymous,[hidden],[hidden]\r\n'
1909-
'3,Chef,[hidden],[hidden]\r\n'
1910-
'4,mary,[hidden],[hidden]\r\n'
1911-
'5,demo,[email protected],%s\r\n'%(passwd)),
1906+
self.assertEqual(s2b('"id","username","address","password"\r\n'
1907+
'"1","admin","[hidden]","[hidden]"\r\n'
1908+
'"2","anonymous","[hidden]","[hidden]"\r\n'
1909+
'"3","Chef","[hidden]","[hidden]"\r\n'
1910+
'"4","mary","[hidden]","[hidden]"\r\n'
1911+
'"5","demo","[email protected]","%s"\r\n'%(passwd)),
19121912
output.getvalue())
19131913

19141914
def testCSVExportWithId(self):
@@ -1919,9 +1919,9 @@ def testCSVExportWithId(self):
19191919
cl.request = MockNull()
19201920
cl.request.wfile = output
19211921
actions.ExportCSVWithIdAction(cl).handle()
1922-
self.assertEqual(s2b('id,name\r\n1,unread\r\n2,deferred\r\n3,chatting\r\n'
1923-
'4,need-eg\r\n5,in-progress\r\n6,testing\r\n7,done-cbb\r\n'
1924-
'8,resolved\r\n'),
1922+
self.assertEqual(s2b('"id","name"\r\n"1","unread"\r\n"2","deferred"\r\n"3","chatting"\r\n'
1923+
'"4","need-eg"\r\n"5","in-progress"\r\n"6","testing"\r\n"7","done-cbb"\r\n'
1924+
'"8","resolved"\r\n'),
19251925
output.getvalue())
19261926

19271927
def testCSVExportWithIdBadColumnName(self):

0 commit comments

Comments
 (0)