~ubuntu-branches/ubuntu/hoary/mailman/hoary-security

« back to all changes in this revision

Viewing changes to Mailman/SecurityManager.py

  • Committer: Bazaar Package Importer
  • Author(s): Matthias Klose
  • Date: 2004-10-11 02:02:43 UTC
  • Revision ID: james.westby@ubuntu.com-20041011020243-ukiishnhlkmsoh21
Tags: upstream-2.1.5
ImportĀ upstreamĀ versionĀ 2.1.5

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 1998-2004 by the Free Software Foundation, Inc.
 
2
#
 
3
# This program is free software; you can redistribute it and/or
 
4
# modify it under the terms of the GNU General Public License
 
5
# as published by the Free Software Foundation; either version 2
 
6
# of the License, or (at your option) any later version.
 
7
#
 
8
# This program is distributed in the hope that it will be useful,
 
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
11
# GNU General Public License for more details.
 
12
#
 
13
# You should have received a copy of the GNU General Public License
 
14
# along with this program; if not, write to the Free Software
 
15
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
 
16
 
 
17
 
 
18
"""Handle passwords and sanitize approved messages."""
 
19
 
 
20
# There are current 5 roles defined in Mailman, as codified in Defaults.py:
 
21
# user, list-creator, list-moderator, list-admin, site-admin.
 
22
#
 
23
# Here's how we do cookie based authentication.
 
24
#
 
25
# Each role (see above) has an associated password, which is currently the
 
26
# only way to authenticate a role (in the future, we'll authenticate a
 
27
# user and assign users to roles).
 
28
#
 
29
# Each cookie has the following ingredients: the authorization context's
 
30
# secret (i.e. the password, and a timestamp.  We generate an SHA1 hex
 
31
# digest of these ingredients, which we call the `mac'.  We then marshal
 
32
# up a tuple of the timestamp and the mac, hexlify that and return that as
 
33
# a cookie keyed off the authcontext.  Note that authenticating the user
 
34
# also requires the user's email address to be included in the cookie.
 
35
#
 
36
# The verification process is done in CheckCookie() below.  It extracts
 
37
# the cookie, unhexlifies and unmarshals the tuple, extracting the
 
38
# timestamp.  Using this, and the shared secret, the mac is calculated,
 
39
# and it must match the mac passed in the cookie.  If so, they're golden,
 
40
# otherwise, access is denied.
 
41
#
 
42
# It is still possible for an adversary to attempt to brute force crack
 
43
# the password if they obtain the cookie, since they can extract the
 
44
# timestamp and create macs based on password guesses.  They never get a
 
45
# cleartext version of the password though, so security rests on the
 
46
# difficulty and expense of retrying the cgi dialog for each attempt.  It
 
47
# also relies on the security of SHA1.
 
48
 
 
49
import os
 
50
import re
 
51
import sha
 
52
import time
 
53
import Cookie
 
54
import marshal
 
55
import binascii
 
56
from types import StringType, TupleType
 
57
from urlparse import urlparse
 
58
 
 
59
try:
 
60
    import crypt
 
61
except ImportError:
 
62
    crypt = None
 
63
import md5
 
64
 
 
65
from Mailman import mm_cfg
 
66
from Mailman import Utils
 
67
from Mailman import Errors
 
68
from Mailman.Logging.Syslog import syslog
 
69
 
 
70
try:
 
71
    True, False
 
72
except NameError:
 
73
    True = 1
 
74
    False = 0
 
75
 
 
76
 
 
77
 
 
78
class SecurityManager:
 
79
    def InitVars(self):
 
80
        # We used to set self.password here, from a crypted_password argument,
 
81
        # but that's been removed when we generalized the mixin architecture.
 
82
        # self.password is really a SecurityManager attribute, but it's set in
 
83
        # MailList.InitVars().
 
84
        self.mod_password = None
 
85
        # Non configurable
 
86
        self.passwords = {}
 
87
 
 
88
    def AuthContextInfo(self, authcontext, user=None):
 
89
        # authcontext may be one of AuthUser, AuthListModerator,
 
90
        # AuthListAdmin, AuthSiteAdmin.  Not supported is the AuthCreator
 
91
        # context.
 
92
        #
 
93
        # user is ignored unless authcontext is AuthUser
 
94
        #
 
95
        # Return the authcontext's secret and cookie key.  If the authcontext
 
96
        # doesn't exist, return the tuple (None, None).  If authcontext is
 
97
        # AuthUser, but the user isn't a member of this mailing list, a
 
98
        # NotAMemberError will be raised.  If the user's secret is None, raise
 
99
        # a MMBadUserError.
 
100
        key = self.internal_name() + '+'
 
101
        if authcontext == mm_cfg.AuthUser:
 
102
            if user is None:
 
103
                # A bad system error
 
104
                raise TypeError, 'No user supplied for AuthUser context'
 
105
            secret = self.getMemberPassword(user)
 
106
            key += 'user+%s' % Utils.ObscureEmail(user)
 
107
        elif authcontext == mm_cfg.AuthListModerator:
 
108
            secret = self.mod_password
 
109
            key += 'moderator'
 
110
        elif authcontext == mm_cfg.AuthListAdmin:
 
111
            secret = self.password
 
112
            key += 'admin'
 
113
        # BAW: AuthCreator
 
114
        elif authcontext == mm_cfg.AuthSiteAdmin:
 
115
            sitepass = Utils.get_global_password()
 
116
            if mm_cfg.ALLOW_SITE_ADMIN_COOKIES and sitepass:
 
117
                secret = sitepass
 
118
                key = 'site'
 
119
            else:
 
120
                # BAW: this should probably hand out a site password based
 
121
                # cookie, but that makes me a bit nervous, so just treat site
 
122
                # admin as a list admin since there is currently no site
 
123
                # admin-only functionality.
 
124
                secret = self.password
 
125
                key += 'admin'
 
126
        else:
 
127
            return None, None
 
128
        return key, secret
 
129
 
 
130
    def Authenticate(self, authcontexts, response, user=None):
 
131
        # Given a list of authentication contexts, check to see if the
 
132
        # response matches one of the passwords.  authcontexts must be a
 
133
        # sequence, and if it contains the context AuthUser, then the user
 
134
        # argument must not be None.
 
135
        #
 
136
        # Return the authcontext from the argument sequence that matches the
 
137
        # response, or UnAuthorized.
 
138
        for ac in authcontexts:
 
139
            if ac == mm_cfg.AuthCreator:
 
140
                ok = Utils.check_global_password(response, siteadmin=0)
 
141
                if ok:
 
142
                    return mm_cfg.AuthCreator
 
143
            elif ac == mm_cfg.AuthSiteAdmin:
 
144
                ok = Utils.check_global_password(response)
 
145
                if ok:
 
146
                    return mm_cfg.AuthSiteAdmin
 
147
            elif ac == mm_cfg.AuthListAdmin:
 
148
                def cryptmatchp(response, secret):
 
149
                    try:
 
150
                        salt = secret[:2]
 
151
                        if crypt and crypt.crypt(response, salt) == secret:
 
152
                            return True
 
153
                        return False
 
154
                    except TypeError:
 
155
                        # BAW: Hard to say why we can get a TypeError here.
 
156
                        # SF bug report #585776 says crypt.crypt() can raise
 
157
                        # this if salt contains null bytes, although I don't
 
158
                        # know how that can happen (perhaps if a MM2.0 list
 
159
                        # with USE_CRYPT = 0 has been updated?  Doubtful.
 
160
                        return False
 
161
                # The password for the list admin and list moderator are not
 
162
                # kept as plain text, but instead as an sha hexdigest.  The
 
163
                # response being passed in is plain text, so we need to
 
164
                # digestify it first.  Note however, that for backwards
 
165
                # compatibility reasons, we'll also check the admin response
 
166
                # against the crypted and md5'd passwords, and if they match,
 
167
                # we'll auto-migrate the passwords to sha.
 
168
                key, secret = self.AuthContextInfo(ac)
 
169
                if secret is None:
 
170
                    continue
 
171
                sharesponse = sha.new(response).hexdigest()
 
172
                upgrade = ok = False
 
173
                if sharesponse == secret:
 
174
                    ok = True
 
175
                elif md5.new(response).digest() == secret:
 
176
                    ok = upgrade = True
 
177
                elif cryptmatchp(response, secret):
 
178
                    ok = upgrade = True
 
179
                if upgrade:
 
180
                    save_and_unlock = False
 
181
                    if not self.Locked():
 
182
                        self.Lock()
 
183
                        save_and_unlock = True
 
184
                    try:
 
185
                        self.password = sharesponse
 
186
                        if save_and_unlock:
 
187
                            self.Save()
 
188
                    finally:
 
189
                        if save_and_unlock:
 
190
                            self.Unlock()
 
191
                if ok:
 
192
                    return ac
 
193
            elif ac == mm_cfg.AuthListModerator:
 
194
                # The list moderator password must be sha'd
 
195
                key, secret = self.AuthContextInfo(ac)
 
196
                if secret and sha.new(response).hexdigest() == secret:
 
197
                    return ac
 
198
            elif ac == mm_cfg.AuthUser:
 
199
                if user is not None:
 
200
                    try:
 
201
                        if self.authenticateMember(user, response):
 
202
                            return ac
 
203
                    except Errors.NotAMemberError:
 
204
                        pass
 
205
            else:
 
206
                # What is this context???
 
207
                syslog('error', 'Bad authcontext: %s', ac)
 
208
                raise ValueError, 'Bad authcontext: %s' % ac
 
209
        return mm_cfg.UnAuthorized
 
210
 
 
211
    def WebAuthenticate(self, authcontexts, response, user=None):
 
212
        # Given a list of authentication contexts, check to see if the cookie
 
213
        # contains a matching authorization, falling back to checking whether
 
214
        # the response matches one of the passwords.  authcontexts must be a
 
215
        # sequence, and if it contains the context AuthUser, then the user
 
216
        # argument should not be None.
 
217
        #
 
218
        # Returns a flag indicating whether authentication succeeded or not.
 
219
        for ac in authcontexts:
 
220
            ok = self.CheckCookie(ac, user)
 
221
            if ok:
 
222
                return True
 
223
        # Check passwords
 
224
        ac = self.Authenticate(authcontexts, response, user)
 
225
        if ac:
 
226
            print self.MakeCookie(ac, user)
 
227
            return True
 
228
        return False
 
229
 
 
230
    def MakeCookie(self, authcontext, user=None):
 
231
        key, secret = self.AuthContextInfo(authcontext, user)
 
232
        if key is None or secret is None or not isinstance(secret, StringType):
 
233
            raise ValueError
 
234
        # Timestamp
 
235
        issued = int(time.time())
 
236
        # Get a digest of the secret, plus other information.
 
237
        mac = sha.new(secret + `issued`).hexdigest()
 
238
        # Create the cookie object.
 
239
        c = Cookie.SimpleCookie()
 
240
        c[key] = binascii.hexlify(marshal.dumps((issued, mac)))
 
241
        # The path to all Mailman stuff, minus the scheme and host,
 
242
        # i.e. usually the string `/mailman'
 
243
        path = urlparse(self.web_page_url)[2]
 
244
        c[key]['path'] = path
 
245
        # We use session cookies, so don't set `expires' or `max-age' keys.
 
246
        # Set the RFC 2109 required header.
 
247
        c[key]['version'] = 1
 
248
        return c
 
249
 
 
250
    def ZapCookie(self, authcontext, user=None):
 
251
        # We can throw away the secret.
 
252
        key, secret = self.AuthContextInfo(authcontext, user)
 
253
        # Logout of the session by zapping the cookie.  For safety both set
 
254
        # max-age=0 (as per RFC2109) and set the cookie data to the empty
 
255
        # string.
 
256
        c = Cookie.SimpleCookie()
 
257
        c[key] = ''
 
258
        # The path to all Mailman stuff, minus the scheme and host,
 
259
        # i.e. usually the string `/mailman'
 
260
        path = urlparse(self.web_page_url)[2]
 
261
        c[key]['path'] = path
 
262
        c[key]['max-age'] = 0
 
263
        # Don't set expires=0 here otherwise it'll force a persistent cookie
 
264
        c[key]['version'] = 1
 
265
        return c
 
266
 
 
267
    def CheckCookie(self, authcontext, user=None):
 
268
        # Two results can occur: we return 1 meaning the cookie authentication
 
269
        # succeeded for the authorization context, we return 0 meaning the
 
270
        # authentication failed.
 
271
        #
 
272
        # Dig out the cookie data, which better be passed on this cgi
 
273
        # environment variable.  If there's no cookie data, we reject the
 
274
        # authentication.
 
275
        cookiedata = os.environ.get('HTTP_COOKIE')
 
276
        if not cookiedata:
 
277
            return False
 
278
        # We can't use the Cookie module here because it isn't liberal in what
 
279
        # it accepts.  Feed it a MM2.0 cookie along with a MM2.1 cookie and
 
280
        # you get a CookieError. :(.  All we care about is accessing the
 
281
        # cookie data via getitem, so we'll use our own parser, which returns
 
282
        # a dictionary.
 
283
        c = parsecookie(cookiedata)
 
284
        # If the user was not supplied, but the authcontext is AuthUser, we
 
285
        # can try to glean the user address from the cookie key.  There may be
 
286
        # more than one matching key (if the user has multiple accounts
 
287
        # subscribed to this list), but any are okay.
 
288
        if authcontext == mm_cfg.AuthUser:
 
289
            if user:
 
290
                usernames = [user]
 
291
            else:
 
292
                usernames = []
 
293
                prefix = self.internal_name() + '+user+'
 
294
                for k in c.keys():
 
295
                    if k.startswith(prefix):
 
296
                        usernames.append(k[len(prefix):])
 
297
            # If any check out, we're golden.  Note: `@'s are no longer legal
 
298
            # values in cookie keys.
 
299
            for user in [Utils.UnobscureEmail(u) for u in usernames]:
 
300
                ok = self.__checkone(c, authcontext, user)
 
301
                if ok:
 
302
                    return True
 
303
            return False
 
304
        else:
 
305
            return self.__checkone(c, authcontext, user)
 
306
 
 
307
    def __checkone(self, c, authcontext, user):
 
308
        # Do the guts of the cookie check, for one authcontext/user
 
309
        # combination.
 
310
        try:
 
311
            key, secret = self.AuthContextInfo(authcontext, user)
 
312
        except Errors.NotAMemberError:
 
313
            return False
 
314
        if not c.has_key(key) or not isinstance(secret, StringType):
 
315
            return False
 
316
        # Undo the encoding we performed in MakeCookie() above.  BAW: I
 
317
        # believe this is safe from exploit because marshal can't be forced to
 
318
        # load recursive data structures, and it can't be forced to execute
 
319
        # any unexpected code.  The worst that can happen is that either the
 
320
        # client will have provided us bogus data, in which case we'll get one
 
321
        # of the caught exceptions, or marshal format will have changed, in
 
322
        # which case, the cookie decoding will fail.  In either case, we'll
 
323
        # simply request reauthorization, resulting in a new cookie being
 
324
        # returned to the client.
 
325
        try:
 
326
            data = marshal.loads(binascii.unhexlify(c[key]))
 
327
            issued, received_mac = data
 
328
        except (EOFError, ValueError, TypeError, KeyError):
 
329
            return False
 
330
        # Make sure the issued timestamp makes sense
 
331
        now = time.time()
 
332
        if now < issued:
 
333
            return False
 
334
        # Calculate what the mac ought to be based on the cookie's timestamp
 
335
        # and the shared secret.
 
336
        mac = sha.new(secret + `issued`).hexdigest()
 
337
        if mac <> received_mac:
 
338
            return False
 
339
        # Authenticated!
 
340
        return True
 
341
 
 
342
 
 
343
 
 
344
splitter = re.compile(';\s*')
 
345
 
 
346
def parsecookie(s):
 
347
    c = {}
 
348
    for p in splitter.split(s):
 
349
        try:
 
350
            k, v = p.split('=', 1)
 
351
        except ValueError:
 
352
            pass
 
353
        else:
 
354
            c[k] = v
 
355
    return c