Skip to content

Commit 6b28d60

Browse files
committed
Harden security paths, shell invocation, and password hashing.
Fix private archive path traversal, replace os.popen/os.system with subprocess, and upgrade site passwords, auth cookies, CSRF tokens, and subscribe form tokens to stronger algorithms while keeping legacy verification for in-place upgrades.
1 parent 23d702c commit 6b28d60

13 files changed

Lines changed: 193 additions & 71 deletions

File tree

Mailman/Archiver/Archiver.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,15 @@ def ExternalArchive(self, ar, txt):
176176
'hostname': self.host_name,
177177
})
178178
cmd = ar % d
179-
extarch = os.popen(cmd, 'w')
180-
extarch.write(txt)
181-
status = extarch.close()
182-
if status:
183-
syslog('error', 'external archiver non-zero exit status: %d\n',
184-
(status & 0xff00) >> 8)
179+
try:
180+
result = Utils.run_command(cmd, input_data=txt)
181+
except (ValueError, OSError) as e:
182+
syslog('error', 'external archiver failed to run: %s', e)
183+
return
184+
if result.returncode:
185+
stderr = result.stderr.decode('utf-8', 'replace') if result.stderr else ''
186+
syslog('error', 'external archiver non-zero exit status: %d\n%s',
187+
result.returncode, stderr)
185188

186189
#
187190
# archiving in real time this is called from list.post(msg)

Mailman/CSRFcheck.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from Mailman import mm_cfg
2626
from Mailman.Logging.Syslog import syslog
27-
from Mailman.Utils import UnobscureEmail, sha_new
27+
from Mailman.Utils import UnobscureEmail, auth_context_mac, verify_auth_context_mac
2828

2929
keydict = {
3030
'user': mm_cfg.AuthUser,
@@ -50,8 +50,7 @@ def csrf_token(mlist, contexts, user=None):
5050
else:
5151
return None # not authenticated
5252
issued = int(time.time())
53-
needs_hash = (secret + repr(issued)).encode('utf-8')
54-
mac = sha_new(needs_hash).hexdigest()
53+
mac = auth_context_mac(secret, issued)
5554
keymac = '%s:%s' % (key, mac)
5655
token = marshal.dumps((issued, keymac)).hex()
5756

@@ -96,9 +95,7 @@ def csrf_check(mlist, token, cgi_user=None):
9695
context = keydict.get(key)
9796
key, secret = mlist.AuthContextInfo(context, user)
9897
assert key
99-
secret = secret + repr(issued)
100-
mac = sha_new(secret.encode()).hexdigest()
101-
if (mac == received_mac
98+
if (verify_auth_context_mac(secret, issued, received_mac)
10299
and 0 < time.time() - issued < mm_cfg.FORM_LIFETIME):
103100
return True
104101
return False

Mailman/Cgi/listinfo.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ def list_listinfo(mlist, lang):
233233
# just to have something to include in the hash below
234234
captcha_idx = ''
235235
secret = mm_cfg.SUBSCRIBE_FORM_SECRET + ":" + now + ":" + captcha_idx + ":" + mlist.internal_name() + ":" + remote
236-
hash_secret = Utils.sha_new(secret.encode('utf-8')).hexdigest()
236+
hash_secret = Utils.subscribe_form_token_digest(secret)
237237
# fill form
238238
replacements['<mm-subscribe-form-start>'] += (
239239
'<input type="hidden" name="sub_form_token"'

Mailman/Cgi/private.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,6 @@
3939
SLASH = '/'
4040

4141

42-
43-
def true_path(path):
44-
"Ensure that the path is safe by removing .."
45-
# Workaround for path traverse vulnerability. Unsuccessful attempts will
46-
# be logged in logs/error.
47-
parts = [x for x in path.split(SLASH) if x not in ('.', '..')]
48-
return SLASH.join(parts)[1:]
49-
50-
5142

5243
def guess_type(url, strict):
5344
if hasattr(mimetypes, 'common_types'):
@@ -68,17 +59,28 @@ def main():
6859
return
6960

7061
path = os.environ.get('PATH_INFO')
71-
tpath = true_path(path)
72-
if tpath != path[1:]:
62+
if not path:
63+
doc.SetTitle(_("Private Archive Error"))
64+
doc.AddItem(Header(3, _("No archive path specified.")))
65+
print(doc.Format())
66+
return
67+
if any(part in ('.', '..') for part in path.split(SLASH)):
7368
msg = _('Private archive - "./" and "../" not allowed in URL.')
7469
doc.SetTitle(msg)
7570
doc.AddItem(Header(2, msg))
7671
print(doc.Format())
7772
syslog('mischief', 'Private archive hostile path: %s', path)
7873
return
79-
# BAW: This needs to be converted to the Site module abstraction
80-
true_filename = os.path.join(
81-
mm_cfg.PRIVATE_ARCHIVE_FILE_DIR, tpath)
74+
try:
75+
true_filename = Utils.safe_path_under(
76+
mm_cfg.PRIVATE_ARCHIVE_FILE_DIR, path)
77+
except ValueError:
78+
msg = _('Private archive - invalid path.')
79+
doc.SetTitle(msg)
80+
doc.AddItem(Header(2, msg))
81+
print(doc.Format())
82+
syslog('mischief', 'Private archive hostile path: %s', path)
83+
return
8284

8385
listname = parts[0].lower()
8486
mboxfile = ''

Mailman/Cgi/subscribe.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,18 +144,17 @@ def process_form(mlist, doc, cgidata, lang):
144144
'response': cgidata.getvalue('g-recaptcha-response', ''),
145145
'remoteip': remote})
146146
request_data = request_data.encode('utf-8')
147-
request = urllib.request.Request(
148-
url = 'https://www.google.com/recaptcha/api/siteverify',
149-
data = request_data)
150147
try:
151-
httpresp = urllib.request.urlopen(request)
148+
httpresp = Utils.safe_urlopen(
149+
'https://www.google.com/recaptcha/api/siteverify',
150+
data=request_data)
152151
captcha_response = json.load(httpresp)
153152
httpresp.close()
154153
if not captcha_response['success']:
155154
e_codes = COMMASPACE.join(captcha_response['error-codes'])
156155
results.append(_(f'reCAPTCHA validation failed: {e_codes}'))
157-
except urllib.error.URLError as e:
158-
e_reason = e.reason
156+
except (urllib.error.URLError, ValueError) as e:
157+
e_reason = getattr(e, 'reason', e)
159158
results.append(_(f'reCAPTCHA could not be validated: {e_reason}'))
160159

161160
# Are we checking the hidden data?
@@ -177,8 +176,8 @@ def process_form(mlist, doc, cgidata, lang):
177176
except ValueError:
178177
ftime = fcaptcha_idx = fhash = ''
179178
then = 0
180-
needs_hashing = (mm_cfg.SUBSCRIBE_FORM_SECRET + ":" + ftime + ":" + fcaptcha_idx + ":" + mlist.internal_name() + ":" + remote1).encode('utf-8')
181-
token = Utils.sha_new(needs_hashing).hexdigest()
179+
needs_hashing = (mm_cfg.SUBSCRIBE_FORM_SECRET + ":" + ftime + ":" + fcaptcha_idx + ":" + mlist.internal_name() + ":" + remote1)
180+
token = Utils.subscribe_form_token_digest(needs_hashing)
182181
if ftime and now - then > mm_cfg.FORM_LIFETIME:
183182
results.append(_('The form is too old. Please GET it again.'))
184183
if ftime and now - then < mm_cfg.SUBSCRIBE_FORM_MIN_TIME:

Mailman/Handlers/Scrubber.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -522,13 +522,16 @@ def save_attachment(mlist, msg, dir, filter_html=True):
522522
fp.write(decodedpayload)
523523
fp.close()
524524
cmd = mm_cfg.ARCHIVE_HTML_SANITIZER % {'filename' : tmppath}
525-
progfp = os.popen(cmd, 'r')
526-
decodedpayload = progfp.read()
527-
status = progfp.close()
528-
if status:
529-
syslog('error',
530-
'HTML sanitizer exited with non-zero status: %s',
531-
status)
525+
try:
526+
result = Utils.run_command(cmd, capture_output=True)
527+
except (ValueError, OSError) as e:
528+
syslog('error', 'HTML sanitizer failed to run: %s', e)
529+
else:
530+
decodedpayload = result.stdout.decode('utf-8', 'replace')
531+
if result.returncode:
532+
syslog('error',
533+
'HTML sanitizer exited with non-zero status: %s',
534+
result.returncode)
532535
finally:
533536
os.unlink(tmppath)
534537
# BAW: Since we've now sanitized the document, it should be plain

Mailman/LockFile.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,7 @@ def _get_logfile():
8989
except ImportError:
9090
# not running inside Mailman
9191
import tempfile
92-
dir = os.path.split(tempfile.mktemp())[0]
93-
path = os.path.join(dir, 'LockFile.log')
92+
path = os.path.join(tempfile.gettempdir(), 'LockFile.log')
9493
# open in line-buffered mode
9594
class SimpleUserFile(object):
9695
def __init__(self, path):

Mailman/MTA/Postfix.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,20 +64,28 @@ def fixom(file):
6464
os.chown(dbfile, uid, gid)
6565
msg = 'command failed: %s (status: %s, %s)'
6666
acmd = mm_cfg.POSTFIX_ALIAS_CMD + ' ' + ALIASFILE
67-
status = (os.system(acmd) >> 8) & 0xff
68-
if status:
69-
errstr = os.strerror(status)
70-
syslog('error', msg, acmd, status, errstr)
71-
raise RuntimeError(msg % (acmd, status, errstr))
67+
try:
68+
aresult = Utils.run_command(acmd)
69+
except (ValueError, OSError) as e:
70+
syslog('error', msg, acmd, -1, e)
71+
raise RuntimeError(msg % (acmd, -1, e))
72+
if aresult.returncode:
73+
errstr = os.strerror(aresult.returncode)
74+
syslog('error', msg, acmd, aresult.returncode, errstr)
75+
raise RuntimeError(msg % (acmd, aresult.returncode, errstr))
7276
# Fix owner and mode of .db if needed.
7377
fixom(ALIASFILE)
7478
if os.path.exists(VIRTFILE):
7579
vcmd = mm_cfg.POSTFIX_MAP_CMD + ' ' + VIRTFILE
76-
status = (os.system(vcmd) >> 8) & 0xff
77-
if status:
78-
errstr = os.strerror(status)
79-
syslog('error', msg, vcmd, status, errstr)
80-
raise RuntimeError(msg % (vcmd, status, errstr))
80+
try:
81+
vresult = Utils.run_command(vcmd)
82+
except (ValueError, OSError) as e:
83+
syslog('error', msg, vcmd, -1, e)
84+
raise RuntimeError(msg % (vcmd, -1, e))
85+
if vresult.returncode:
86+
errstr = os.strerror(vresult.returncode)
87+
syslog('error', msg, vcmd, vresult.returncode, errstr)
88+
raise RuntimeError(msg % (vcmd, vresult.returncode, errstr))
8189
# Fix owner and mode of .db if needed.
8290
fixom(VIRTFILE)
8391

Mailman/SecurityManager.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@
6363
from Mailman import Utils
6464
from Mailman import Errors
6565
from Mailman.Logging.Syslog import syslog
66-
from Mailman.Utils import sha_new, hash_password, verify_password
66+
from Mailman.Utils import hash_password, verify_password
67+
from Mailman.Utils import auth_context_mac, verify_auth_context_mac
6768

6869

6970
class SecurityManager(object):
@@ -317,9 +318,7 @@ def MakeCookie(self, authcontext, user=None):
317318
raise ValueError
318319
# Timestamp
319320
issued = int(time.time())
320-
# Get a digest of the secret, plus other information.
321-
needs_hashing = (secret + repr(issued)).encode('utf-8')
322-
mac = sha_new(needs_hashing).hexdigest()
321+
mac = auth_context_mac(secret, issued)
323322
# Create the cookie object.
324323
c = http.cookies.SimpleCookie()
325324
c[key] = binascii.hexlify(marshal.dumps((issued, mac))).decode()
@@ -427,9 +426,7 @@ def __checkone(self, c, authcontext, user):
427426
return False
428427
# Calculate what the mac ought to be based on the cookie's timestamp
429428
# and the shared secret.
430-
needs_hashing = (secret + repr(issued)).encode('utf-8')
431-
mac = sha_new(needs_hashing).hexdigest()
432-
if mac != received_mac:
429+
if not verify_auth_context_mac(secret, issued, received_mac):
433430
return False
434431
# Authenticated!
435432
# Refresh the cookie

0 commit comments

Comments
 (0)