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