Merge lp:~tkikuchi/mailman/form-lifetime into lp:mailman/2.1

Proposed by Tokio Kikuchi
Status: Merged
Merge reported by: Mark Sapiro
Merged at revision: not available
Proposed branch: lp:~tkikuchi/mailman/form-lifetime
Merge into: lp:mailman/2.1
Diff against target: 429 lines (+190/-12)
8 files modified
Mailman/CSRFcheck.py (+73/-0)
Mailman/Cgi/admin.py (+24/-5)
Mailman/Cgi/admindb.py (+22/-2)
Mailman/Cgi/edithtml.py (+22/-2)
Mailman/Cgi/options.py (+27/-1)
Mailman/Defaults.py.in (+3/-0)
Mailman/HTMLFormatter.py (+8/-1)
Mailman/htmlformat.py (+11/-1)
To merge this branch: bzr merge lp:~tkikuchi/mailman/form-lifetime
Reviewer Review Type Date Requested Status
Mark Sapiro Pending
Review via email: mp+64107@code.launchpad.net

Description of the change

Setting lifetime for input forms is useful in protecting lists and user settings from cross-site request forgery (CSRf).
The form generation time is set by a hidden parameter whose value is calculated following the mailman cookie algorithm. The default lifetime is set 1 hour in Default.py thus configurable by a site administrator. If a password is set in request, authorization cookie is discarded so the password authentication is forced.
This code has been in operation for more than a month on my sites and is considered to be stable.

To post a comment you must log in.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'Mailman/CSRFcheck.py'
2--- Mailman/CSRFcheck.py 1970-01-01 00:00:00 +0000
3+++ Mailman/CSRFcheck.py 2011-06-10 01:46:29 +0000
4@@ -0,0 +1,73 @@
5+# Copyright (C) 2011 by the Free Software Foundation, Inc.
6+#
7+# This program is free software; you can redistribute it and/or
8+# modify it under the terms of the GNU General Public License
9+# as published by the Free Software Foundation; either version 2
10+# of the License, or (at your option) any later version.
11+#
12+# This program is distributed in the hope that it will be useful,
13+# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+# GNU General Public License for more details.
16+#
17+# You should have received a copy of the GNU General Public License
18+# along with this program; if not, write to the Free Software
19+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
20+# USA.
21+
22+""" Cross-Site Request Forgery checker """
23+
24+import time
25+import marshal
26+import binascii
27+
28+from Mailman import mm_cfg
29+from Mailman.Utils import sha_new
30+
31+keydict = {
32+ 'user': mm_cfg.AuthUser,
33+ 'poster': mm_cfg.AuthListPoster,
34+ 'moderator': mm_cfg.AuthListModerator,
35+ 'admin': mm_cfg.AuthListAdmin,
36+ 'site': mm_cfg.AuthSiteAdmin,
37+}
38+
39+
40+def csrf_token(mlist, contexts, user=None):
41+ """ create token by mailman cookie generation algorithm """
42+
43+ for context in contexts:
44+ key, secret = mlist.AuthContextInfo(context, user)
45+ if key:
46+ break
47+ else:
48+ return None # not authenticated
49+ issued = int(time.time())
50+ mac = sha_new(secret + `issued`).hexdigest()
51+ keymac = '%s:%s' % (key, mac)
52+ token = binascii.hexlify(marshal.dumps((issued, keymac)))
53+ return token
54+
55+
56+def csrf_check(mlist, token):
57+ """ check token by mailman cookie validation algorithm """
58+
59+ try:
60+ issued, keymac = marshal.loads(binascii.unhexlify(token))
61+ key, received_mac = keymac.split(':', 1)
62+ klist, key = key.split('+', 1)
63+ assert klist == mlist.internal_name()
64+ if '+' in key:
65+ key, user = key.split('+', 1)
66+ else:
67+ user = None
68+ context = keydict.get(key)
69+ key, secret = mlist.AuthContextInfo(context, user)
70+ assert key
71+ mac = sha_new(secret + `issued`).hexdigest()
72+ if (mac == received_mac
73+ and 0 < time.time() - issued < mm_cfg.FORM_LIFETIME):
74+ return True
75+ return False
76+ except (AssertionError, ValueError, TypeError):
77+ return False
78
79=== modified file 'Mailman/Cgi/admin.py'
80--- Mailman/Cgi/admin.py 2011-04-25 23:52:35 +0000
81+++ Mailman/Cgi/admin.py 2011-06-10 01:46:29 +0000
82@@ -41,6 +41,7 @@
83 from Mailman.Cgi import Auth
84 from Mailman.Logging.Syslog import syslog
85 from Mailman.Utils import sha_new
86+from Mailman.CSRFcheck import csrf_check
87
88 # Set up i18n
89 _ = i18n._
90@@ -55,6 +56,7 @@
91 True = 1
92 False = 0
93
94+AUTH_CONTEXTS = (mm_cfg.AuthListAdmin, mm_cfg.AuthSiteAdmin)
95
96
97
98 def main():
99@@ -83,6 +85,18 @@
100 # If the user is not authenticated, we're done.
101 cgidata = cgi.FieldStorage(keep_blank_values=1)
102
103+ # CSRF check
104+ safe_params = ['VARHELP', 'adminpw', 'admlogin']
105+ params = cgidata.keys()
106+ if set(params) - set(safe_params):
107+ csrf_checked = csrf_check(mlist, cgidata.getvalue('csrf_token'))
108+ else:
109+ csrf_checked = True
110+ # if password is present, void cookie to force password authentication.
111+ if cgidata.getvalue('adminpw'):
112+ os.environ['HTTP_COOKIE'] = ''
113+ csrf_checked = True
114+
115 if not mlist.WebAuthenticate((mm_cfg.AuthListAdmin,
116 mm_cfg.AuthSiteAdmin),
117 cgidata.getvalue('adminpw', '')):
118@@ -174,8 +188,12 @@
119 signal.signal(signal.SIGTERM, sigterm_handler)
120
121 if cgidata.keys():
122- # There are options to change
123- change_options(mlist, category, subcat, cgidata, doc)
124+ if csrf_checked:
125+ # There are options to change
126+ change_options(mlist, category, subcat, cgidata, doc)
127+ else:
128+ doc.addError(
129+ _('The form lifetime has expired. (request forgery check)'))
130 # Let the list sanity check the changed values
131 mlist.CheckValues()
132 # Additional sanity checks
133@@ -362,7 +380,7 @@
134 url = '%s/%s/%s' % (mlist.GetScriptURL('admin'), category, subcat)
135 else:
136 url = '%s/%s' % (mlist.GetScriptURL('admin'), category)
137- form = Form(url)
138+ form = Form(url, mlist=mlist, contexts=AUTH_CONTEXTS)
139 valtab = Table(cellspacing=3, cellpadding=4, width='100%')
140 add_options_table_item(mlist, category, subcat, valtab, item, detailsp=0)
141 form.AddItem(valtab)
142@@ -408,9 +426,10 @@
143 encoding = 'multipart/form-data'
144 if subcat:
145 form = Form('%s/%s/%s' % (adminurl, category, subcat),
146- encoding=encoding)
147+ encoding=encoding, mlist=mlist, contexts=AUTH_CONTEXTS)
148 else:
149- form = Form('%s/%s' % (adminurl, category), encoding=encoding)
150+ form = Form('%s/%s' % (adminurl, category),
151+ encoding=encoding, mlist=mlist, contexts=AUTH_CONTEXTS)
152 # This holds the two columns of links
153 linktable = Table(valign='top', width='100%')
154 linktable.AddRow([Center(Bold(_("Configuration Categories"))),
155
156=== modified file 'Mailman/Cgi/admindb.py'
157--- Mailman/Cgi/admindb.py 2011-05-11 01:57:55 +0000
158+++ Mailman/Cgi/admindb.py 2011-06-10 01:46:29 +0000
159@@ -39,6 +39,7 @@
160 from Mailman.Cgi import Auth
161 from Mailman.htmlformat import *
162 from Mailman.Logging.Syslog import syslog
163+from Mailman.CSRFcheck import csrf_check
164
165 EMPTYSTRING = ''
166 NL = '\n'
167@@ -51,6 +52,9 @@
168 EXCERPT_HEIGHT = 10
169 EXCERPT_WIDTH = 76
170
171+AUTH_CONTEXTS = (mm_cfg.AuthListAdmin, mm_cfg.AuthSiteAdmin,
172+ mm_cfg.AuthListModerator)
173+
174
175
176
177 def helds_by_sender(mlist):
178@@ -100,6 +104,18 @@
179 # Make sure the user is authorized to see this page.
180 cgidata = cgi.FieldStorage(keep_blank_values=1)
181
182+ # CSRF check
183+ safe_params = ['adminpw', 'admlogin', 'msgid', 'sender', 'details']
184+ params = cgidata.keys()
185+ if set(params) - set(safe_params):
186+ csrf_checked = csrf_check(mlist, cgidata.getvalue('csrf_token'))
187+ else:
188+ csrf_checked = True
189+ # if password is present, void cookie to force password authentication.
190+ if cgidata.getvalue('adminpw'):
191+ os.environ['HTTP_COOKIE'] = ''
192+ csrf_checked = True
193+
194 if not mlist.WebAuthenticate((mm_cfg.AuthListAdmin,
195 mm_cfg.AuthListModerator,
196 mm_cfg.AuthSiteAdmin),
197@@ -177,7 +193,11 @@
198 elif not details:
199 # This is a form submission
200 doc.SetTitle(_('%(realname)s Administrative Database Results'))
201- process_form(mlist, doc, cgidata)
202+ if csrf_checked:
203+ process_form(mlist, doc, cgidata)
204+ else:
205+ doc.addError(
206+ _('The form lifetime has expired. (request forgery check)'))
207 # Now print the results and we're done. Short circuit for when there
208 # are no pending requests, but be sure to save the results!
209 admindburl = mlist.GetScriptURL('admindb', absolute=1)
210@@ -198,7 +218,7 @@
211 mlist.Save()
212 return
213
214- form = Form(admindburl)
215+ form = Form(admindburl, mlist=mlist, contexts=AUTH_CONTEXTS)
216 # Add the instructions template
217 if details == 'instructions':
218 doc.AddItem(Header(
219
220=== modified file 'Mailman/Cgi/edithtml.py'
221--- Mailman/Cgi/edithtml.py 2010-03-29 20:48:11 +0000
222+++ Mailman/Cgi/edithtml.py 2011-06-10 01:46:29 +0000
223@@ -30,9 +30,12 @@
224 from Mailman.Cgi import Auth
225 from Mailman.Logging.Syslog import syslog
226 from Mailman import i18n
227+from Mailman.CSRFcheck import csrf_check
228
229 _ = i18n._
230
231+AUTH_CONTEXTS = (mm_cfg.AuthListAdmin, mm_cfg.AuthSiteAdmin)
232+
233
234
235
236 def main():
237@@ -81,6 +84,18 @@
238 # Must be authenticated to get any farther
239 cgidata = cgi.FieldStorage()
240
241+ # CSRF check
242+ safe_params = ['VARHELP', 'adminpw', 'admlogin']
243+ params = cgidata.keys()
244+ if set(params) - set(safe_params):
245+ csrf_checked = csrf_check(mlist, cgidata.getvalue('csrf_token'))
246+ else:
247+ csrf_checked = True
248+ # if password is present, void cookie to force password authentication.
249+ if cgidata.getvalue('adminpw'):
250+ os.environ['HTTP_COOKIE'] = ''
251+ csrf_checked = True
252+
253 # Editing the html for a list is limited to the list admin and site admin.
254 if not mlist.WebAuthenticate((mm_cfg.AuthListAdmin,
255 mm_cfg.AuthSiteAdmin),
256@@ -125,7 +140,11 @@
257
258 try:
259 if cgidata.keys():
260- ChangeHTML(mlist, cgidata, template_name, doc)
261+ if csrf_checked:
262+ ChangeHTML(mlist, cgidata, template_name, doc)
263+ else:
264+ doc.addError(
265+ _('The form lifetime has expired. (request forgery check)'))
266 FormatHTML(mlist, doc, template_name, template_info)
267 finally:
268 doc.AddItem(mlist.GetMailmanFooter())
269@@ -144,7 +163,8 @@
270 doc.AddItem(FontSize("+1", link))
271 doc.AddItem('<p>')
272 doc.AddItem('<hr>')
273- form = Form(mlist.GetScriptURL('edithtml') + '/' + template_name)
274+ form = Form(mlist.GetScriptURL('edithtml') + '/' + template_name,
275+ mlist=mlist, contexts=AUTH_CONTEXTS)
276 text = Utils.maketext(template_name, raw=1, mlist=mlist)
277 # MAS: Don't websafe twice. TextArea does it.
278 form.AddItem(TextArea('html_code', text, rows=40, cols=75))
279
280=== modified file 'Mailman/Cgi/options.py'
281--- Mailman/Cgi/options.py 2011-06-07 22:41:51 +0000
282+++ Mailman/Cgi/options.py 2011-06-10 01:46:29 +0000
283@@ -32,6 +32,7 @@
284 from Mailman import i18n
285 from Mailman.htmlformat import *
286 from Mailman.Logging.Syslog import syslog
287+from Mailman.CSRFcheck import csrf_check
288
289 SLASH = '/'
290 SETLANGUAGE = -1
291@@ -46,6 +47,8 @@
292 True = 1
293 False = 0
294
295+AUTH_CONTEXTS = (mm_cfg.AuthListAdmin, mm_cfg.AuthSiteAdmin,
296+ mm_cfg.AuthListModerator, mm_cfg.AuthUser)
297
298
299
300 def main():
301@@ -87,6 +90,19 @@
302 # The total contents of the user's response
303 cgidata = cgi.FieldStorage(keep_blank_values=1)
304
305+ # CSRF check
306+ safe_params = ['displang-button', 'language', 'email', 'password', 'login',
307+ 'login-unsub', 'login-remind', 'VARHELP', 'UserOptions']
308+ params = cgidata.keys()
309+ if set(params) - set(safe_params):
310+ csrf_checked = csrf_check(mlist, cgidata.getvalue('csrf_token'))
311+ else:
312+ csrf_checked = True
313+ # if password is present, void cookie to force password authentication.
314+ if cgidata.getvalue('password'):
315+ os.environ['HTTP_COOKIE'] = ''
316+ csrf_checked = True
317+
318 # Set the language for the page. If we're coming from the listinfo cgi,
319 # we might have a 'language' key in the cgi data. That was an explicit
320 # preference to view the page in, so we should honor that here. If that's
321@@ -265,6 +281,15 @@
322 # options. The first set of checks does not require the list to be
323 # locked.
324
325+ # Before going further, get the result of CSRF check and do nothing
326+ # if it has failed.
327+ if csrf_checked == False:
328+ doc.addError(
329+ _('The form lifetime has expired. (request forgery check)'))
330+ options_page(mlist, doc, user, cpuser, userlang)
331+ print doc.Format()
332+ return
333+
334 if cgidata.has_key('logout'):
335 print mlist.ZapCookie(mm_cfg.AuthUser, user)
336 loginpage(mlist, doc, user, language)
337@@ -775,7 +800,8 @@
338 mlist.FormatButton('othersubs',
339 _('List my other subscriptions')))
340 replacements['<mm-form-start>'] = (
341- mlist.FormatFormStart('options', user))
342+ mlist.FormatFormStart('options', user, mlist=mlist,
343+ contexts=AUTH_CONTEXTS, user=user))
344 replacements['<mm-user>'] = user
345 replacements['<mm-presentable-user>'] = presentable_user
346 replacements['<mm-email-my-pw>'] = mlist.FormatButton(
347
348=== modified file 'Mailman/Defaults.py.in'
349--- Mailman/Defaults.py.in 2011-05-01 16:21:29 +0000
350+++ Mailman/Defaults.py.in 2011-06-10 01:46:29 +0000
351@@ -108,6 +108,9 @@
352 # expire that many seconds following their last use.
353 AUTHENTICATION_COOKIE_LIFETIME = 0
354
355+# Form lifetime is set against Cross Site Request Forgery.
356+FORM_LIFETIME = hours(1)
357+
358 # Command that is used to convert text/html parts into plain text. This
359 # should output results to standard output. %(filename)s will contain the
360 # name of the temporary file that the program should operate on.
361
362=== modified file 'Mailman/HTMLFormatter.py'
363--- Mailman/HTMLFormatter.py 2010-09-09 15:16:57 +0000
364+++ Mailman/HTMLFormatter.py 2011-06-10 01:46:29 +0000
365@@ -28,6 +28,8 @@
366
367 from Mailman.i18n import _
368
369+from Mailman.CSRFcheck import csrf_token
370+
371
372 EMPTYSTRING = ''
373 BR = '<br>'
374@@ -314,12 +316,17 @@
375 container.AddItem("</center>")
376 return container
377
378- def FormatFormStart(self, name, extra=''):
379+ def FormatFormStart(self, name, extra='',
380+ mlist=None, contexts=None, user=None):
381 base_url = self.GetScriptURL(name)
382 if extra:
383 full_url = "%s/%s" % (base_url, extra)
384 else:
385 full_url = base_url
386+ if mlist:
387+ return ("""<form method="POST" action="%s">
388+<input type="hidden" name="csrf_token" value="%s">"""
389+ % (full_url, csrf_token(mlist, contexts, user)))
390 return ('<FORM Method=POST ACTION="%s">' % full_url)
391
392 def FormatArchiveAnchor(self):
393
394=== modified file 'Mailman/htmlformat.py'
395--- Mailman/htmlformat.py 2007-11-25 08:04:30 +0000
396+++ Mailman/htmlformat.py 2011-06-10 01:46:29 +0000
397@@ -34,6 +34,8 @@
398 from Mailman import Utils
399 from Mailman.i18n import _
400
401+from Mailman.CSRFcheck import csrf_token
402+
403 SPACE = ' '
404 EMPTYSTRING = ''
405 NL = '\n'
406@@ -402,11 +404,15 @@
407 tag = 'center'
408
409 class Form(Container):
410- def __init__(self, action='', method='POST', encoding=None, *items):
411+ def __init__(self, action='', method='POST', encoding=None,
412+ mlist=None, contexts=None, user=None, *items):
413 apply(Container.__init__, (self,) + items)
414 self.action = action
415 self.method = method
416 self.encoding = encoding
417+ self.mlist = mlist
418+ self.contexts = contexts
419+ self.user = user
420
421 def set_action(self, action):
422 self.action = action
423@@ -418,6 +424,10 @@
424 encoding = 'enctype="%s"' % self.encoding
425 output = '\n%s<FORM action="%s" method="%s" %s>\n' % (
426 spaces, self.action, self.method, encoding)
427+ if self.mlist:
428+ output = output + \
429+ '<input type="hidden" name="csrf_token" value="%s">\n' \
430+ % csrf_token(self.mlist, self.contexts, self.user)
431 output = output + Container.Format(self, indent+2)
432 output = '%s\n%s</FORM>\n' % (output, spaces)
433 return output