diff options
Diffstat (limited to '')
-rw-r--r-- | Mailman/SecurityManager.py | 333 |
1 files changed, 333 insertions, 0 deletions
diff --git a/Mailman/SecurityManager.py b/Mailman/SecurityManager.py new file mode 100644 index 00000000..8b65738e --- /dev/null +++ b/Mailman/SecurityManager.py @@ -0,0 +1,333 @@ +# Copyright (C) 1998,1999,2000,2001,2002 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + + +"""Handle passwords and sanitize approved messages.""" + +# There are current 5 roles defined in Mailman, as codified in Defaults.py: +# user, list-creator, list-moderator, list-admin, site-admin. +# +# Here's how we do cookie based authentication. +# +# Each role (see above) has an associated password, which is currently the +# only way to authenticate a role (in the future, we'll authenticate a +# user and assign users to roles). +# +# Each cookie has the following ingredients: the authorization context's +# secret (i.e. the password, and a timestamp. We generate an SHA1 hex +# digest of these ingredients, which we call the `mac'. We then marshal +# up a tuple of the timestamp and the mac, hexlify that and return that as +# a cookie keyed off the authcontext. Note that authenticating the user +# also requires the user's email address to be included in the cookie. +# +# The verification process is done in CheckCookie() below. It extracts +# the cookie, unhexlifies and unmarshals the tuple, extracting the +# timestamp. Using this, and the shared secret, the mac is calculated, +# and it must match the mac passed in the cookie. If so, they're golden, +# otherwise, access is denied. +# +# It is still possible for an adversary to attempt to brute force crack +# the password if they obtain the cookie, since they can extract the +# timestamp and create macs based on password guesses. They never get a +# cleartext version of the password though, so security rests on the +# difficulty and expense of retrying the cgi dialog for each attempt. It +# also relies on the security of SHA1. + +import os +import time +import sha +import marshal +import binascii +import Cookie +from types import StringType, TupleType +from urlparse import urlparse + +try: + import crypt +except ImportError: + crypt = None +import md5 + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman.Logging.Syslog import syslog + + + +class SecurityManager: + def InitVars(self): + # We used to set self.password here, from a crypted_password argument, + # but that's been removed when we generalized the mixin architecture. + # self.password is really a SecurityManager attribute, but it's set in + # MailList.InitVars(). + self.mod_password = None + # Non configurable + self.passwords = {} + + def AuthContextInfo(self, authcontext, user=None): + # authcontext may be one of AuthUser, AuthListModerator, + # AuthListAdmin, AuthSiteAdmin. Not supported is the AuthCreator + # context. + # + # user is ignored unless authcontext is AuthUser + # + # Return the authcontext's secret and cookie key. If the authcontext + # doesn't exist, return the tuple (None, None). If authcontext is + # AuthUser, but the user isn't a member of this mailing list, a + # NotAMemberError will be raised. If the user's secret is None, raise + # a MMBadUserError. + key = self.internal_name() + '+' + if authcontext == mm_cfg.AuthUser: + if user is None: + # A bad system error + raise TypeError, 'No user supplied for AuthUser context' + secret = self.getMemberPassword(user) + key += 'user+%s' % Utils.ObscureEmail(user) + elif authcontext == mm_cfg.AuthListModerator: + secret = self.mod_password + key += 'moderator' + elif authcontext == mm_cfg.AuthListAdmin: + secret = self.password + key += 'admin' + # BAW: AuthCreator + elif authcontext == mm_cfg.AuthSiteAdmin: + sitepass = Utils.get_global_password() + if mm_cfg.ALLOW_SITE_ADMIN_COOKIES and sitepass: + secret = sitepass + key = 'site' + else: + # BAW: this should probably hand out a site password based + # cookie, but that makes me a bit nervous, so just treat site + # admin as a list admin since there is currently no site + # admin-only functionality. + secret = self.password + key += 'admin' + else: + return None, None + return key, secret + + def Authenticate(self, authcontexts, response, user=None): + # Given a list of authentication contexts, check to see if the + # response matches one of the passwords. authcontexts must be a + # sequence, and if it contains the context AuthUser, then the user + # argument must not be None. + # + # Return the authcontext from the argument sequence that matches the + # response, or UnAuthorized. + for ac in authcontexts: + if ac == mm_cfg.AuthCreator: + ok = Utils.check_global_password(response, siteadmin=0) + if ok: + return mm_cfg.AuthCreator + elif ac == mm_cfg.AuthSiteAdmin: + ok = Utils.check_global_password(response) + if ok: + return mm_cfg.AuthSiteAdmin + elif ac == mm_cfg.AuthListAdmin: + def cryptmatchp(response, secret): + try: + salt = secret[:2] + if crypt and crypt.crypt(response, salt) == secret: + return 1 + return 0 + except TypeError: + # BAW: Hard to say why we can get a TypeError here. + # SF bug report #585776 says crypt.crypt() can raise + # this if salt contains null bytes, although I don't + # know how that can happen (perhaps if a MM2.0 list + # with USE_CRYPT = 0 has been updated? Doubtful. + return 0 + # The password for the list admin and list moderator are not + # kept as plain text, but instead as an sha hexdigest. The + # response being passed in is plain text, so we need to + # digestify it first. Note however, that for backwards + # compatibility reasons, we'll also check the admin response + # against the crypted and md5'd passwords, and if they match, + # we'll auto-migrate the passwords to sha. + key, secret = self.AuthContextInfo(ac) + if secret is None: + continue + sharesponse = sha.new(response).hexdigest() + upgrade = ok = 0 + if sharesponse == secret: + ok = 1 + elif md5.new(response).digest() == secret: + ok = 1 + upgrade = 1 + elif cryptmatchp(response, secret): + ok = 1 + upgrade = 1 + if upgrade: + save_and_unlock = 0 + if not self.Locked(): + self.Lock() + save_and_unlock = 1 + try: + self.password = sharesponse + if save_and_unlock: + self.Save() + finally: + if save_and_unlock: + self.Unlock() + if ok: + return ac + elif ac == mm_cfg.AuthListModerator: + # The list moderator password must be sha'd + key, secret = self.AuthContextInfo(ac) + if secret and sha.new(response).hexdigest() == secret: + return ac + elif ac == mm_cfg.AuthUser: + if self.authenticateMember(user, response): + return ac + else: + # What is this context??? + syslog('error', 'Bad authcontext: %s', ac) + raise ValueError, 'Bad authcontext: %s' % ac + return mm_cfg.UnAuthorized + + def WebAuthenticate(self, authcontexts, response, user=None): + # Given a list of authentication contexts, check to see if the cookie + # contains a matching authorization, falling back to checking whether + # the response matches one of the passwords. authcontexts must be a + # sequence, and if it contains the context AuthUser, then the user + # argument must not be None. + # + # Returns a flag indicating whether authentication succeeded or not. + try: + for ac in authcontexts: + ok = self.CheckCookie(ac, user) + if ok: + return 1 + # Check passwords + ac = self.Authenticate(authcontexts, response, user) + if ac: + print self.MakeCookie(ac, user) + return 1 + except Errors.NotAMemberError: + pass + return 0 + + def MakeCookie(self, authcontext, user=None): + key, secret = self.AuthContextInfo(authcontext, user) + if key is None or secret is None or not isinstance(secret, StringType): + raise ValueError + # Timestamp + issued = int(time.time()) + # Get a digest of the secret, plus other information. + mac = sha.new(secret + `issued`).hexdigest() + # Create the cookie object. + c = Cookie.SimpleCookie() + c[key] = binascii.hexlify(marshal.dumps((issued, mac))) + # The path to all Mailman stuff, minus the scheme and host, + # i.e. usually the string `/mailman' + path = urlparse(self.web_page_url)[2] + c[key]['path'] = path + # We use session cookies, so don't set `expires' or `max-age' keys. + # Set the RFC 2109 required header. + c[key]['version'] = 1 + return c + + def ZapCookie(self, authcontext, user=None): + # We can throw away the secret. + key, secret = self.AuthContextInfo(authcontext, user) + # Logout of the session by zapping the cookie. For safety both set + # max-age=0 (as per RFC2109) and set the cookie data to the empty + # string. + c = Cookie.SimpleCookie() + c[key] = '' + # The path to all Mailman stuff, minus the scheme and host, + # i.e. usually the string `/mailman' + path = urlparse(self.web_page_url)[2] + c[key]['path'] = path + c[key]['max-age'] = 0 + # Don't set expires=0 here otherwise it'll force a persistent cookie + c[key]['version'] = 1 + return c + + def CheckCookie(self, authcontext, user=None): + # Two results can occur: we return 1 meaning the cookie authentication + # succeeded for the authorization context, we return 0 meaning the + # authentication failed. + # + # Dig out the cookie data, which better be passed on this cgi + # environment variable. If there's no cookie data, we reject the + # authentication. + cookiedata = os.environ.get('HTTP_COOKIE') + if not cookiedata: + return 0 + # Treat the cookie data as simple strings, and do application level + # decoding as necessary. By using SimpleCookie, we prevent any kind + # of security breach due to untrusted cookie data being unpickled + # (which is quite unsafe). + try: + c = Cookie.SimpleCookie(cookiedata) + except Cookie.CookieError: + return 0 + # If the user was not supplied, but the authcontext is AuthUser, we + # can try to glean the user address from the cookie key. There may be + # more than one matching key (if the user has multiple accounts + # subscribed to this list), but any are okay. + if authcontext == mm_cfg.AuthUser: + if user: + usernames = [user] + else: + usernames = [] + prefix = self.internal_name() + '+user+' + for k in c.keys(): + if k.startswith(prefix): + usernames.append(k[len(prefix):]) + # If any check out, we're golden. Note: `@'s are no longer legal + # values in cookie keys. + for user in [Utils.UnobscureEmail(u) for u in usernames]: + ok = self.__checkone(c, authcontext, user) + if ok: + return 1 + return 0 + else: + return self.__checkone(c, authcontext, user) + + def __checkone(self, c, authcontext, user): + # Do the guts of the cookie check, for one authcontext/user + # combination. + key, secret = self.AuthContextInfo(authcontext, user) + if not c.has_key(key) or not isinstance(secret, StringType): + return 0 + # Undo the encoding we performed in MakeCookie() above. BAW: I + # believe this is safe from exploit because marshal can't be forced to + # load recursive data structures, and it can't be forced to execute + # any unexpected code. The worst that can happen is that either the + # client will have provided us bogus data, in which case we'll get one + # of the caught exceptions, or marshal format will have changed, in + # which case, the cookie decoding will fail. In either case, we'll + # simply request reauthorization, resulting in a new cookie being + # returned to the client. + try: + data = marshal.loads(binascii.unhexlify(c[key].value)) + issued, received_mac = data + except (EOFError, ValueError, TypeError, KeyError): + return 0 + # Make sure the issued timestamp makes sense + now = time.time() + if now < issued: + return 0 + # Calculate what the mac ought to be based on the cookie's timestamp + # and the shared secret. + mac = sha.new(secret + `issued`).hexdigest() + if mac <> received_mac: + return 0 + # Authenticated! + return 1 |