1# Copyright (C) 1998-2018 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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, 16# USA. 17 18"""Provide a password-interface wrapper around private archives.""" 19 20import os 21import sys 22import cgi 23import mimetypes 24 25from Mailman import mm_cfg 26from Mailman import Utils 27from Mailman import MailList 28from Mailman import Errors 29from Mailman import i18n 30from Mailman.htmlformat import * 31from Mailman.Logging.Syslog import syslog 32 33# Set up i18n. Until we know which list is being requested, we use the 34# server's default. 35_ = i18n._ 36i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) 37 38SLASH = '/' 39 40 41 42def true_path(path): 43 "Ensure that the path is safe by removing .." 44 # Workaround for path traverse vulnerability. Unsuccessful attempts will 45 # be logged in logs/error. 46 parts = [x for x in path.split(SLASH) if x not in ('.', '..')] 47 return SLASH.join(parts)[1:] 48 49 50 51def guess_type(url, strict): 52 if hasattr(mimetypes, 'common_types'): 53 return mimetypes.guess_type(url, strict) 54 return mimetypes.guess_type(url) 55 56 57 58def main(): 59 doc = Document() 60 doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) 61 62 parts = Utils.GetPathPieces() 63 if not parts: 64 doc.SetTitle(_("Private Archive Error")) 65 doc.AddItem(Header(3, _("You must specify a list."))) 66 print doc.Format() 67 return 68 69 path = os.environ.get('PATH_INFO') 70 tpath = true_path(path) 71 if tpath <> path[1:]: 72 msg = _('Private archive - "./" and "../" not allowed in URL.') 73 doc.SetTitle(msg) 74 doc.AddItem(Header(2, msg)) 75 print doc.Format() 76 syslog('mischief', 'Private archive hostile path: %s', path) 77 return 78 # BAW: This needs to be converted to the Site module abstraction 79 true_filename = os.path.join( 80 mm_cfg.PRIVATE_ARCHIVE_FILE_DIR, tpath) 81 82 listname = parts[0].lower() 83 mboxfile = '' 84 if len(parts) > 1: 85 mboxfile = parts[1] 86 87 # See if it's the list's mbox file is being requested 88 if listname.endswith('.mbox') and mboxfile.endswith('.mbox') and \ 89 listname[:-5] == mboxfile[:-5]: 90 listname = listname[:-5] 91 else: 92 mboxfile = '' 93 94 # If it's a directory, we have to append index.html in this script. We 95 # must also check for a gzipped file, because the text archives are 96 # usually stored in compressed form. 97 if os.path.isdir(true_filename): 98 true_filename = true_filename + '/index.html' 99 if not os.path.exists(true_filename) and \ 100 os.path.exists(true_filename + '.gz'): 101 true_filename = true_filename + '.gz' 102 103 try: 104 mlist = MailList.MailList(listname, lock=0) 105 except Errors.MMListError, e: 106 # Avoid cross-site scripting attacks 107 safelistname = Utils.websafe(listname) 108 msg = _('No such list <em>%(safelistname)s</em>') 109 doc.SetTitle(_("Private Archive Error - %(msg)s")) 110 doc.AddItem(Header(2, msg)) 111 # Send this with a 404 status. 112 print 'Status: 404 Not Found' 113 print doc.Format() 114 syslog('error', 'private: No such list "%s": %s\n', listname, e) 115 return 116 117 i18n.set_language(mlist.preferred_language) 118 doc.set_language(mlist.preferred_language) 119 120 cgidata = cgi.FieldStorage() 121 try: 122 username = cgidata.getfirst('username', '').strip() 123 except TypeError: 124 # Someone crafted a POST with a bad Content-Type:. 125 doc.AddItem(Header(2, _("Error"))) 126 doc.AddItem(Bold(_('Invalid options to CGI script.'))) 127 # Send this with a 400 status. 128 print 'Status: 400 Bad Request' 129 print doc.Format() 130 return 131 password = cgidata.getfirst('password', '') 132 133 is_auth = 0 134 realname = mlist.real_name 135 message = '' 136 137 if not mlist.WebAuthenticate((mm_cfg.AuthUser, 138 mm_cfg.AuthListModerator, 139 mm_cfg.AuthListAdmin, 140 mm_cfg.AuthSiteAdmin), 141 password, username): 142 if cgidata.has_key('submit'): 143 # This is a re-authorization attempt 144 message = Bold(FontSize('+1', _('Authorization failed.'))).Format() 145 remote = os.environ.get('HTTP_FORWARDED_FOR', 146 os.environ.get('HTTP_X_FORWARDED_FOR', 147 os.environ.get('REMOTE_ADDR', 148 'unidentified origin'))) 149 syslog('security', 150 'Authorization failed (private): user=%s: list=%s: remote=%s', 151 username, listname, remote) 152 # give an HTTP 401 for authentication failure 153 print 'Status: 401 Unauthorized' 154 # Are we processing a password reminder from the login screen? 155 if cgidata.has_key('login-remind'): 156 if username: 157 message = Bold(FontSize('+1', _("""If you are a list member, 158 your password has been emailed to you."""))).Format() 159 else: 160 message = Bold(FontSize('+1', 161 _('Please enter your email address'))).Format() 162 if mlist.isMember(username): 163 mlist.MailUserPassword(username) 164 elif username: 165 # Not a member. Don't report address in any case. It leads to 166 # Content injection. Just log if roster is not public. 167 if mlist.private_roster != 0: 168 syslog('mischief', 169 'Reminder attempt of non-member w/ private rosters: %s', 170 username) 171 # Output the password form 172 charset = Utils.GetCharSet(mlist.preferred_language) 173 print 'Content-type: text/html; charset=' + charset + '\n\n' 174 # Put the original full path in the authorization form, but avoid 175 # trailing slash if we're not adding parts. We add it below. 176 action = mlist.GetScriptURL('private', absolute=1) 177 if mboxfile: 178 action += '.mbox' 179 if parts[1:]: 180 action = os.path.join(action, SLASH.join(parts[1:])) 181 # If we added '/index.html' to true_filename, add a slash to the URL. 182 # We need this because we no longer add the trailing slash in the 183 # private.html template. It's always OK to test parts[-1] since we've 184 # already verified parts[0] is listname. The basic rule is if the 185 # post URL (action) is a directory, it must be slash terminated, but 186 # not if it's a file. Otherwise, relative links in the target archive 187 # page don't work. 188 if true_filename.endswith('/index.html') and parts[-1] <> 'index.html': 189 action += SLASH 190 # Escape web input parameter to avoid cross-site scripting. 191 print Utils.maketext( 192 'private.html', 193 {'action' : Utils.websafe(action), 194 'realname': mlist.real_name, 195 'message' : message, 196 }, mlist=mlist) 197 return 198 199 lang = mlist.getMemberLanguage(username) 200 i18n.set_language(lang) 201 doc.set_language(lang) 202 203 # Authorization confirmed... output the desired file 204 try: 205 ctype, enc = guess_type(path, strict=0) 206 if ctype is None: 207 ctype = 'text/html' 208 if mboxfile: 209 f = open(os.path.join(mlist.archive_dir() + '.mbox', 210 mlist.internal_name() + '.mbox')) 211 ctype = 'text/plain' 212 elif true_filename.endswith('.gz'): 213 import gzip 214 f = gzip.open(true_filename, 'r') 215 else: 216 f = open(true_filename, 'r') 217 except IOError: 218 msg = _('Private archive file not found') 219 doc.SetTitle(msg) 220 doc.AddItem(Header(2, msg)) 221 print 'Status: 404 Not Found' 222 print doc.Format() 223 syslog('error', 'Private archive file not found: %s', true_filename) 224 else: 225 print 'Content-type: %s\n' % ctype 226 sys.stdout.write(f.read()) 227 f.close() 228