Skip to content

Commit 27ef29f

Browse files
author
Richard Jones
committed
Plug a number of security holes:
- EditCSV and ExportCSV altered to include permission checks - HTTP POST required on actions which alter data - HTML file uploads served as application/octet-stream - New item action reject creation of new users - Item retirement was not being controlled Additionally include documentation of the changes and modify affected tests.
1 parent 8295039 commit 27ef29f

File tree

12 files changed

+227
-48
lines changed

12 files changed

+227
-48
lines changed

CHANGES.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
This file contains the changes to the Roundup system over time. The entries
22
are given with the most recent entry first.
33

4+
2009-03-?? 1.4.7
5+
6+
Fixes:
7+
- a number of security issues were discovered by Daniel Diniz
8+
- EditCSV and ExportCSV altered to include permission checks
9+
- HTTP POST required on actions which alter data
10+
- HTML file uploads served as application/octet-stream
11+
- New item action reject creation of new users
12+
- Item retirement was not being controlled
13+
- XXX need to include Stefan's changes in here too
14+
15+
416
2008-09-01 1.4.6
517
Fixed:
618
- Fix bug introduced in 1.4.5 in RDBMS full-text indexing

doc/customizing.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ Section **tracker**
181181
LC_MESSAGES, or LANG, in that order of preference.
182182

183183
Section **web**
184+
allow_html_file -- ``no``
185+
Setting this option enables Roundup to serve uploaded HTML
186+
file content *as HTML*. This is a potential security risk
187+
and is therefore disabled by default. Set to 'yes' if you
188+
trust *all* users uploading content to your tracker.
189+
184190
http_auth -- ``yes``
185191
Whether to use HTTP Basic Authentication, if present.
186192
Roundup will use either the REMOTE_USER or HTTP_AUTHORIZATION

doc/upgrading.txt

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,71 @@ steps.
1313

1414
.. contents::
1515

16+
17+
Migrating from 1.4.x to 1.4.7
18+
=============================
19+
20+
Several security issues were addressed in this release. Some aspects of your
21+
trackers may no longer function depending on your local customisations. Core
22+
functionality that will need to be modified:
23+
24+
Grant the "retire" permission to users for their queries
25+
--------------------------------------------------------
26+
27+
Users will no longer be able to retire their own queries. To remedy this you
28+
will need to add the following to your tracker's ``schema.py`` just under the
29+
line that grants them permission to edit their own queries::
30+
31+
p = db.security.addPermission(name='Edit', klass='query', check=edit_query,
32+
description="User is allowed to edit their queries")
33+
db.security.addPermissionToRole('User', p)
34+
+ p = db.security.addPermission(name='Retire', klass='query', check=edit_query,
35+
+ description="User is allowed to retire their queries")
36+
+ db.security.addPermissionToRole('User', p)
37+
p = db.security.addPermission(name='Create', klass='query',
38+
description="User is allowed to create queries")
39+
db.security.addPermissionToRole('User', p)
40+
41+
The lines marked "+" should be added, minus the "+" sign.
42+
43+
44+
Fix the "retire" link in the users list for admin users
45+
-------------------------------------------------------
46+
47+
The "retire" link found in the file ``html/users.index.html``::
48+
49+
<td tal:condition="context/is_edit_ok">
50+
<a tal:attributes="href string:user${user/id}?@action=retire&@template=index"
51+
i18n:translate="">retire</a>
52+
53+
Should be replaced with::
54+
55+
<td tal:condition="context/is_retire_ok">
56+
<form style="padding:0"
57+
tal:attributes="action string:user${user/id}">
58+
<input type="hidden" name="@template" value="index">
59+
<input type="hidden" name="@action" value="retire">
60+
<input type="submit" value="retire" i18n:attributes="value">
61+
</form>
62+
63+
64+
Trackers currently allowing HTML file uploading
65+
-----------------------------------------------
66+
67+
Trackers which wish to continue to allow uploading of HTML content against issues
68+
will need to set a new configuration variable in the ``[web]`` section of the
69+
tracker's ``config.ini`` file:
70+
71+
# Setting this option enables Roundup to serve uploaded HTML
72+
# file content *as HTML*. This is a potential security risk
73+
# and is therefore disabled by default. Set to 'yes' if you
74+
# trust *all* users uploading content to your tracker.
75+
# Allowed values: yes, no
76+
# Default: no
77+
allow_html_file = no
78+
79+
80+
1681
Migrating from 1.4.2 to 1.4.3
1782
=============================
1883

roundup/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,6 @@
6868
'''
6969
__docformat__ = 'restructuredtext'
7070

71-
__version__ = '1.4.6'
71+
__version__ = '1.4.7'
7272

7373
# vim: set filetype=python ts=4 sw=4 et si

roundup/cgi/actions.py

Lines changed: 89 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -103,30 +103,37 @@ class RetireAction(Action):
103103

104104
def handle(self):
105105
"""Retire the context item."""
106-
# if we want to view the index template now, then unset the nodeid
106+
# ensure modification comes via POST
107+
if self.client.env['REQUEST_METHOD'] != 'POST':
108+
self.client.error_message.append(self._('Invalid request'))
109+
110+
# if we want to view the index template now, then unset the itemid
107111
# context info (a special-case for retire actions on the index page)
108-
nodeid = self.nodeid
112+
itemid = self.nodeid
109113
if self.template == 'index':
110114
self.client.nodeid = None
111115

112116
# make sure we don't try to retire admin or anonymous
113117
if self.classname == 'user' and \
114-
self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
118+
self.db.user.get(itemid, 'username') in ('admin', 'anonymous'):
115119
raise ValueError, self._(
116120
'You may not retire the admin or anonymous user')
117121

122+
# check permission
123+
if not self.hasPermission('Retire', classname=self.classname,
124+
itemid=itemid):
125+
raise exceptions.Unauthorised, self._(
126+
'You do not have permission to retire %(class)s'
127+
) % {'class': self.classname}
128+
118129
# do the retire
119-
self.db.getclass(self.classname).retire(nodeid)
130+
self.db.getclass(self.classname).retire(itemid)
120131
self.db.commit()
121132

122133
self.client.ok_message.append(
123134
self._('%(classname)s %(itemid)s has been retired')%{
124-
'classname': self.classname.capitalize(), 'itemid': nodeid})
135+
'classname': self.classname.capitalize(), 'itemid': itemid})
125136

126-
def hasPermission(self, permission, classname=Action._marker, itemid=None):
127-
if itemid is None:
128-
itemid = self.nodeid
129-
return Action.hasPermission(self, permission, classname, itemid)
130137

131138
class SearchAction(Action):
132139
name = 'search'
@@ -274,12 +281,19 @@ def handle(self):
274281
The "rows" CGI var defines the CSV-formatted entries for the class. New
275282
nodes are identified by the ID 'X' (or any other non-existent ID) and
276283
removed lines are retired.
277-
278284
"""
285+
# ensure modification comes via POST
286+
if self.client.env['REQUEST_METHOD'] != 'POST':
287+
self.client.error_message.append(self._('Invalid request'))
288+
289+
# figure the properties list for the class
279290
cl = self.db.classes[self.classname]
280-
idlessprops = cl.getprops(protected=0).keys()
281-
idlessprops.sort()
282-
props = ['id'] + idlessprops
291+
props_without_id = cl.getprops(protected=0).keys()
292+
293+
# the incoming CSV data will always have the properties in colums
294+
# sorted and starting with the "id" column
295+
props_without_id.sort()
296+
props = ['id'] + props_without_id
283297

284298
# do the edit
285299
rows = StringIO.StringIO(self.form['rows'].value)
@@ -293,25 +307,38 @@ def handle(self):
293307
if values == props:
294308
continue
295309

296-
# extract the nodeid
297-
nodeid, values = values[0], values[1:]
298-
found[nodeid] = 1
310+
# extract the itemid
311+
itemid, values = values[0], values[1:]
312+
found[itemid] = 1
299313

300314
# see if the node exists
301-
if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
315+
if itemid in ('x', 'X') or not cl.hasnode(itemid):
302316
exists = 0
317+
318+
# check permission to create this item
319+
if not self.hasPermission('Create', classname=self.classname):
320+
raise exceptions.Unauthorised, self._(
321+
'You do not have permission to create %(class)s'
322+
) % {'class': self.classname}
303323
else:
304324
exists = 1
305325

306326
# confirm correct weight
307-
if len(idlessprops) != len(values):
327+
if len(props_without_id) != len(values):
308328
self.client.error_message.append(
309329
self._('Not enough values on line %(line)s')%{'line':line})
310330
return
311331

312332
# extract the new values
313333
d = {}
314-
for name, value in zip(idlessprops, values):
334+
for name, value in zip(props_without_id, values):
335+
# check permission to edit this property on this item
336+
if exists and not self.hasPermission('Edit', itemid=itemid,
337+
classname=self.classname, property=name):
338+
raise exceptions.Unauthorised, self._(
339+
'You do not have permission to edit %(class)s'
340+
) % {'class': self.classname}
341+
315342
prop = cl.properties[name]
316343
value = value.strip()
317344
# only add the property if it has a value
@@ -340,15 +367,21 @@ def handle(self):
340367
# perform the edit
341368
if exists:
342369
# edit existing
343-
cl.set(nodeid, **d)
370+
cl.set(itemid, **d)
344371
else:
345372
# new node
346373
found[cl.create(**d)] = 1
347374

348375
# retire the removed entries
349-
for nodeid in cl.list():
350-
if not found.has_key(nodeid):
351-
cl.retire(nodeid)
376+
for itemid in cl.list():
377+
if not found.has_key(itemid):
378+
# check permission to retire this item
379+
if not self.hasPermission('Retire', itemid=itemid,
380+
classname=self.classname):
381+
raise exceptions.Unauthorised, self._(
382+
'You do not have permission to retire %(class)s'
383+
) % {'class': self.classname}
384+
cl.retire(itemid)
352385

353386
# all OK
354387
self.db.commit()
@@ -493,10 +526,8 @@ def editItemPermission(self, props, classname=_cn_marker, itemid=None):
493526
# The user must have permission to edit each of the properties
494527
# being changed.
495528
for p in props:
496-
if not self.hasPermission('Edit',
497-
itemid=itemid,
498-
classname=classname,
499-
property=p):
529+
if not self.hasPermission('Edit', itemid=itemid,
530+
classname=classname, property=p):
500531
return 0
501532
# Since the user has permission to edit all of the properties,
502533
# the edit is OK.
@@ -554,6 +585,10 @@ def handle(self):
554585
See parsePropsFromForm and _editnodes for special variables.
555586
556587
"""
588+
# ensure modification comes via POST
589+
if self.client.env['REQUEST_METHOD'] != 'POST':
590+
self.client.error_message.append(self._('Invalid request'))
591+
557592
user_activity = self.lastUserActivity()
558593
if user_activity:
559594
props = self.detectCollision(user_activity, self.lastNodeActivity())
@@ -596,6 +631,10 @@ def handle(self):
596631
This follows the same form as the EditItemAction, with the same
597632
special form values.
598633
'''
634+
# ensure modification comes via POST
635+
if self.client.env['REQUEST_METHOD'] != 'POST':
636+
self.client.error_message.append(self._('Invalid request'))
637+
599638
# parse the props from the form
600639
try:
601640
props, links = self.client.parsePropsFromForm(create=1)
@@ -604,6 +643,11 @@ def handle(self):
604643
% str(message))
605644
return
606645

646+
# guard against new user creation that would bypass security checks
647+
for key in props:
648+
if 'user' in key:
649+
return
650+
607651
# handle the props - edit or create
608652
try:
609653
# when it hits the None element, it'll set self.nodeid
@@ -773,6 +817,10 @@ def handle(self):
773817
774818
Return 1 on successful login.
775819
"""
820+
# ensure modification comes via POST
821+
if self.client.env['REQUEST_METHOD'] != 'POST':
822+
self.client.error_message.append(self._('Invalid request'))
823+
776824
# parse the props from the form
777825
try:
778826
props, links = self.client.parsePropsFromForm(create=1)
@@ -887,6 +935,10 @@ def handle(self):
887935
Sets up a session for the user which contains the login credentials.
888936
889937
"""
938+
# ensure modification comes via POST
939+
if self.client.env['REQUEST_METHOD'] != 'POST':
940+
self.client.error_message.append(self._('Invalid request'))
941+
890942
# we need the username at a minimum
891943
if not self.form.has_key('__login_name'):
892944
self.client.error_message.append(self._('Username required'))
@@ -986,7 +1038,16 @@ def handle(self):
9861038

9871039
# and search
9881040
for itemid in klass.filter(matches, filterspec, sort, group):
989-
self.client._socket_op(writer.writerow, [str(klass.get(itemid, col)) for col in columns])
1041+
row = []
1042+
for name in columns:
1043+
# check permission to view this property on this item
1044+
if exists and not self.hasPermission('View', itemid=itemid,
1045+
classname=request.classname, property=name):
1046+
raise exceptions.Unauthorised, self._(
1047+
'You do not have permission to view %(class)s'
1048+
) % {'class': request.classname}
1049+
row.append(str(klass.get(itemid, name)))
1050+
self.client._socket_op(writer.writerow, row)
9901051

9911052
return '\n'
9921053

roundup/cgi/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,13 @@ def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
853853

854854
mime_type = klass.get(nodeid, 'type')
855855

856+
# if the mime_type is HTML-ish then make sure we're allowed to serve up
857+
# HTML-ish content
858+
if mime_type in ('text/html', 'text/x-html'):
859+
if not self.instance.config['WEB_ALLOW_HTML_FILE']:
860+
# do NOT serve the content up as HTML
861+
mime_type = 'application/octet-stream'
862+
856863
# If this object is a file (i.e., an instance of FileClass),
857864
# see if we can find it in the filesystem. If so, we may be
858865
# able to use the more-efficient request.sendfile method of

0 commit comments

Comments
 (0)