prevent SMTP smuggling
[ansible.git] / roles / email / files / mailman-patched / Cgi / subscribe.py
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 """Process subscription or roster requests from listinfo form."""
19
20 import sys
21 import os
22 import cgi
23 import time
24 import signal
25 import urllib
26 import urllib2
27 import json
28
29 from Mailman import mm_cfg
30 from Mailman import Utils
31 from Mailman import Captcha # MAILMAN_CAPTCHA_PATCHED
32 from Mailman import MailList
33 from Mailman import Errors
34 from Mailman import i18n
35 from Mailman import Message
36 from Mailman.UserDesc import UserDesc
37 from Mailman.htmlformat import *
38 from Mailman.Logging.Syslog import syslog
39
40 SLASH = '/'
41 ERRORSEP = '\n\n<p>'
42 COMMASPACE = ', '
43
44 # Set up i18n
45 _ = i18n._
46 i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
47
48
49 \f
50 def main():
51     doc = Document()
52     doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
53
54     parts = Utils.GetPathPieces()
55     if not parts:
56         doc.AddItem(Header(2, _("Error")))
57         doc.AddItem(Bold(_('Invalid options to CGI script')))
58         print doc.Format()
59         return
60
61     listname = parts[0].lower()
62     try:
63         mlist = MailList.MailList(listname, lock=0)
64     except Errors.MMListError, e:
65         # Avoid cross-site scripting attacks
66         safelistname = Utils.websafe(listname)
67         doc.AddItem(Header(2, _("Error")))
68         doc.AddItem(Bold(_('No such list <em>%(safelistname)s</em>')))
69         # Send this with a 404 status.
70         print 'Status: 404 Not Found'
71         print doc.Format()
72         syslog('error', 'subscribe: No such list "%s": %s\n', listname, e)
73         return
74
75     # See if the form data has a preferred language set, in which case, use it
76     # for the results.  If not, use the list's preferred language.
77     cgidata = cgi.FieldStorage()
78     try:
79         language = cgidata.getfirst('language', '')
80     except TypeError:
81         # Someone crafted a POST with a bad Content-Type:.
82         doc.AddItem(Header(2, _("Error")))
83         doc.AddItem(Bold(_('Invalid options to CGI script.')))
84         # Send this with a 400 status.
85         print 'Status: 400 Bad Request'
86         print doc.Format()
87         return
88     if not Utils.IsLanguage(language):
89         language = mlist.preferred_language
90     i18n.set_language(language)
91     doc.set_language(language)
92
93     # We need a signal handler to catch the SIGTERM that can come from Apache
94     # when the user hits the browser's STOP button.  See the comment in
95     # admin.py for details.
96     #
97     # BAW: Strictly speaking, the list should not need to be locked just to
98     # read the request database.  However the request database asserts that
99     # the list is locked in order to load it and it's not worth complicating
100     # that logic.
101     def sigterm_handler(signum, frame, mlist=mlist):
102         # Make sure the list gets unlocked...
103         mlist.Unlock()
104         # ...and ensure we exit, otherwise race conditions could cause us to
105         # enter MailList.Save() while we're in the unlocked state, and that
106         # could be bad!
107         sys.exit(0)
108
109     mlist.Lock()
110     try:
111         # Install the emergency shutdown signal handler
112         signal.signal(signal.SIGTERM, sigterm_handler)
113
114         process_form(mlist, doc, cgidata, language)
115         mlist.Save()
116     finally:
117         mlist.Unlock()
118
119
120 \f
121 def process_form(mlist, doc, cgidata, lang):
122     listowner = mlist.GetOwnerEmail()
123     realname = mlist.real_name
124     results = []
125
126     # The email address being subscribed, required
127     email = cgidata.getfirst('email', '').strip()
128     if not email:
129         results.append(_('You must supply a valid email address.'))
130
131     fullname = cgidata.getfirst('fullname', '')
132     # Canonicalize the full name
133     fullname = Utils.canonstr(fullname, lang)
134     # Who was doing the subscribing?
135     remote = os.environ.get('HTTP_FORWARDED_FOR',
136              os.environ.get('HTTP_X_FORWARDED_FOR',
137              os.environ.get('REMOTE_ADDR',
138                             'unidentified origin')))
139
140     # Check reCAPTCHA submission, if enabled
141     if mm_cfg.RECAPTCHA_SECRET_KEY:
142         request = urllib2.Request(
143             url = 'https://www.google.com/recaptcha/api/siteverify',
144             data = urllib.urlencode({
145                 'secret': mm_cfg.RECAPTCHA_SECRET_KEY,
146                 'response': cgidata.getvalue('g-recaptcha-response', ''),
147                 'remoteip': remote}))
148         try:
149             httpresp = urllib2.urlopen(request)
150             captcha_response = json.load(httpresp)
151             httpresp.close()
152             if not captcha_response['success']:
153                 e_codes = COMMASPACE.join(captcha_response['error-codes'])
154                 results.append(_('reCAPTCHA validation failed: %(e_codes)s'))
155         except urllib2.URLError, e:
156             e_reason = e.reason
157             results.append(_('reCAPTCHA could not be validated: %(e_reason)s'))
158
159     # Are we checking the hidden data?
160     if mm_cfg.SUBSCRIBE_FORM_SECRET:
161         now = int(time.time())
162         # Try to accept a range in case of load balancers, etc.  (LP: #1447445)
163         if remote.find('.') >= 0:
164             # ipv4 - drop last octet
165             remote1 = remote.rsplit('.', 1)[0]
166         else:
167             # ipv6 - drop last 16 (could end with :: in which case we just
168             #        drop one : resulting in an invalid format, but it's only
169             #        for our hash so it doesn't matter.
170             remote1 = remote.rsplit(':', 1)[0]
171         try:
172             ftime, fcaptcha_idx, fhash = cgidata.getfirst('sub_form_token', '').split(':')
173             then = int(ftime)
174         except ValueError:
175             ftime = fcaptcha_idx = fhash = ''
176             then = 0
177         token = Utils.sha_new(mm_cfg.SUBSCRIBE_FORM_SECRET + ":" +
178                               ftime + ":" +
179                               fcaptcha_idx + ":" +
180                               mlist.internal_name() + ":" +
181                               remote1).hexdigest()
182         if ftime and now - then > mm_cfg.FORM_LIFETIME:
183             results.append(_('The form is too old.  Please GET it again.'))
184         if ftime and now - then < mm_cfg.SUBSCRIBE_FORM_MIN_TIME:
185             results.append(
186     _('Please take a few seconds to fill out the form before submitting it.'))
187         if ftime and token != fhash:
188             results.append(
189                 _("The hidden token didn't match.  Did your IP change?"))
190         if not ftime:
191             results.append(
192     _('There was no hidden token in your submission or it was corrupted.'))
193             results.append(_('You must GET the form before submitting it.'))
194     # Check captcha
195     captcha_answer = cgidata.getvalue('captcha_answer', '')
196     if not Captcha.verify(fcaptcha_idx, captcha_answer, mm_cfg.CAPTCHAS):
197         results.append(_('This was not the right answer to the CAPTCHA question.'))
198     # Was an attempt made to subscribe the list to itself?
199     if email == mlist.GetListEmail():
200         syslog('mischief', 'Attempt to self subscribe %s: %s', email, remote)
201         results.append(_('You may not subscribe a list to itself!'))
202     # If the user did not supply a password, generate one for him
203     password = cgidata.getfirst('pw', '').strip()
204     confirmed = cgidata.getfirst('pw-conf', '').strip()
205
206     if not password and not confirmed:
207         password = Utils.MakeRandomPassword()
208     elif not password or not confirmed:
209         results.append(_('If you supply a password, you must confirm it.'))
210     elif password <> confirmed:
211         results.append(_('Your passwords did not match.'))
212
213     # Get the digest option for the subscription.
214     digestflag = cgidata.getfirst('digest')
215     if digestflag:
216         try:
217             digest = int(digestflag)
218         except (TypeError, ValueError):
219             digest = 0
220     else:
221         digest = mlist.digest_is_default
222
223     # Sanity check based on list configuration.  BAW: It's actually bogus that
224     # the page allows you to set the digest flag if you don't really get the
225     # choice. :/
226     if not mlist.digestable:
227         digest = 0
228     elif not mlist.nondigestable:
229         digest = 1
230
231     if results:
232         print_results(mlist, ERRORSEP.join(results), doc, lang)
233         return
234
235     # If this list has private rosters, we have to be careful about the
236     # message that gets printed, otherwise the subscription process can be
237     # used to mine for list members.  It may be inefficient, but it's still
238     # possible, and that kind of defeats the purpose of private rosters.
239     # We'll use this string for all successful or unsuccessful subscription
240     # results.
241     if mlist.private_roster == 0:
242         # Public rosters
243         privacy_results = ''
244     else:
245         privacy_results = _("""\
246 Your subscription request has been received, and will soon be acted upon.
247 Depending on the configuration of this mailing list, your subscription request
248 may have to be first confirmed by you via email, or approved by the list
249 moderator.  If confirmation is required, you will soon get a confirmation
250 email which contains further instructions.""")
251
252     try:
253         userdesc = UserDesc(email, fullname, password, digest, lang)
254         mlist.AddMember(userdesc, remote)
255         results = ''
256     # Check for all the errors that mlist.AddMember can throw options on the
257     # web page for this cgi
258     except Errors.MembershipIsBanned:
259         results = _("""The email address you supplied is banned from this
260         mailing list.  If you think this restriction is erroneous, please
261         contact the list owners at %(listowner)s.""")
262     except Errors.MMBadEmailError:
263         results = _("""\
264 The email address you supplied is not valid.  (E.g. it must contain an
265 `@'.)""")
266     except Errors.MMHostileAddress:
267         results = _("""\
268 Your subscription is not allowed because the email address you gave is
269 insecure.""")
270     except Errors.MMSubscribeNeedsConfirmation:
271         # Results string depends on whether we have private rosters or not
272         if privacy_results:
273             results = privacy_results
274         else:
275             results = _("""\
276 Confirmation from your email address is required, to prevent anyone from
277 subscribing you without permission.  Instructions are being sent to you at
278 %(email)s.  Please note your subscription will not start until you confirm
279 your subscription.""")
280     except Errors.MMNeedApproval, x:
281         # Results string depends on whether we have private rosters or not
282         if privacy_results:
283             results = privacy_results
284         else:
285             # We need to interpolate into x.__str__()
286             x = _(str(x))
287             results = _("""\
288 Your subscription request was deferred because %(x)s.  Your request has been
289 forwarded to the list moderator.  You will receive email informing you of the
290 moderator's decision when they get to your request.""")
291     except Errors.MMAlreadyAMember:
292         # Results string depends on whether we have private rosters or not
293         if not privacy_results:
294             results = _('You are already subscribed.')
295         else:
296             results = privacy_results
297             # This could be a membership probe.  For safety, let the user know
298             # a probe occurred.  BAW: should we inform the list moderator?
299             listaddr = mlist.GetListEmail()
300             # Set the language for this email message to the member's language.
301             mlang = mlist.getMemberLanguage(email)
302             otrans = i18n.get_translation()
303             i18n.set_language(mlang)
304             try:
305                 msg = Message.UserNotification(
306                     mlist.getMemberCPAddress(email),
307                     mlist.GetBouncesEmail(),
308                     _('Mailman privacy alert'),
309                     _("""\
310 An attempt was made to subscribe your address to the mailing list
311 %(listaddr)s.  You are already subscribed to this mailing list.
312
313 Note that the list membership is not public, so it is possible that a bad
314 person was trying to probe the list for its membership.  This would be a
315 privacy violation if we let them do this, but we didn't.
316
317 If you submitted the subscription request and forgot that you were already
318 subscribed to the list, then you can ignore this message.  If you suspect that
319 an attempt is being made to covertly discover whether you are a member of this
320 list, and you are worried about your privacy, then feel free to send a message
321 to the list administrator at %(listowner)s.
322 """), lang=mlang)
323             finally:
324                 i18n.set_translation(otrans)
325             msg.send(mlist)
326     # These shouldn't happen unless someone's tampering with the form
327     except Errors.MMCantDigestError:
328         results = _('This list does not support digest delivery.')
329     except Errors.MMMustDigestError:
330         results = _('This list only supports digest delivery.')
331     else:
332         # Everything's cool.  Our return string actually depends on whether
333         # this list has private rosters or not
334         if privacy_results:
335             results = privacy_results
336         else:
337             results = _("""\
338 You have been successfully subscribed to the %(realname)s mailing list.""")
339     # Show the results
340     print_results(mlist, results, doc, lang)
341
342
343 \f
344 def print_results(mlist, results, doc, lang):
345     # The bulk of the document will come from the options.html template, which
346     # includes its own html armor (head tags, etc.).  Suppress the head that
347     # Document() derived pages get automatically.
348     doc.suppress_head = 1
349
350     replacements = mlist.GetStandardReplacements(lang)
351     replacements['<mm-results>'] = results
352     output = mlist.ParseTags('subscribe.html', replacements, lang)
353     doc.AddItem(output)
354     print doc.Format()