Merge lp:~tkikuchi/mailman/form-lifetime into lp:mailman/2.1
- form-lifetime
- Merge into 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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Mark Sapiro | Pending | ||
Review via email: mp+64107@code.launchpad.net |
Commit message
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 |