Skip to content

Commit 26ba1dc

Browse files
jared mauchjared mauch
authored andcommitted
Add password upgrade notifications and dry-run option to bin/update
- Add check_and_notify_password_upgrades() function to scan lists for old SHA1 passwords - Send email notifications to list administrators asking them to login - Add auto-upgrade support for global passwords when used for authentication - Add --dry-run option to preview password upgrade checks without sending emails - Update check_global_password() to support auto-upgrade parameter When bin/update runs, it now: - Checks all lists for old password formats - Sends emails to list owners asking them to login (which triggers auto-upgrade) - Detects old global passwords and notes they'll upgrade on next use - Supports --dry-run mode to preview without sending emails
1 parent 8b0024f commit 26ba1dc

3 files changed

Lines changed: 186 additions & 9 deletions

File tree

Mailman/SecurityManager.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,13 @@ def Authenticate(self, authcontexts, response, user=None):
142142

143143
for ac in authcontexts:
144144
if ac == mm_cfg.AuthCreator:
145-
ok = Utils.check_global_password(response, siteadmin=0)
145+
# Auto-upgrade global passwords when used for authentication
146+
ok = Utils.check_global_password(response, siteadmin=0, auto_upgrade=True)
146147
if ok:
147148
return mm_cfg.AuthCreator
148149
elif ac == mm_cfg.AuthSiteAdmin:
149-
ok = Utils.check_global_password(response)
150+
# Auto-upgrade global passwords when used for authentication
151+
ok = Utils.check_global_password(response, auto_upgrade=True)
150152
if ok:
151153
return mm_cfg.AuthSiteAdmin
152154
elif ac == mm_cfg.AuthListAdmin:

Mailman/Utils.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -825,14 +825,25 @@ def get_global_password(siteadmin=True):
825825
return challenge
826826

827827

828-
def check_global_password(response, siteadmin=True):
828+
def check_global_password(response, siteadmin=True, auto_upgrade=False):
829+
"""Check a global password and optionally upgrade it if in old format.
830+
831+
Args:
832+
response: The password to check (str or bytes)
833+
siteadmin: If True, check site admin password; if False, check list creator password
834+
auto_upgrade: If True, automatically upgrade old format passwords to new format
835+
836+
Returns:
837+
bool: True if password is valid, False otherwise
838+
"""
829839
challenge = get_global_password(siteadmin)
830840
if challenge is None:
831841
return None
832842
# Use verify_password which handles both old SHA1 and new PBKDF2 formats
833843
is_valid, needs_upgrade = verify_password(response, challenge)
834-
# Note: We don't auto-upgrade global passwords here since they're in files
835-
# and we'd need to write back to the file. This could be added if needed.
844+
# Auto-upgrade if requested and password is valid but in old format
845+
if is_valid and needs_upgrade and auto_upgrade:
846+
set_global_password(response, siteadmin)
836847
return is_valid
837848

838849

bin/update

Lines changed: 168 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ Options:
2727
of the installed Mailman matches the current version number (or a
2828
`downgrade' is detected), nothing will be done.
2929

30+
-n/--dry-run
31+
Show what would be done without actually making changes. When checking
32+
for password upgrades, this will show which lists need upgrades but
33+
will not send email notifications to list administrators.
34+
3035
-h/--help
3136
Print this text and exit.
3237

@@ -55,6 +60,7 @@ from Mailman import Message
5560
from Mailman import Pending
5661
from Mailman.LockFile import TimeOutError
5762
from Mailman.i18n import C_
63+
import Mailman.i18n as i18n
5864
from Mailman.Queue.Switchboard import Switchboard
5965
from Mailman.OldStyleMemberships import OldStyleMemberships
6066
from Mailman.MemberAdaptor import BYBOUNCE, ENABLED
@@ -663,7 +669,158 @@ def update_pending():
663669

664670

665671

666-
def main():
672+
def check_and_notify_password_upgrades(dry_run=False):
673+
"""Check for old password formats and notify list admins to login and upgrade.
674+
675+
This function identifies lists with old SHA1 password hashes and sends
676+
email notifications to list administrators asking them to login, which
677+
will trigger automatic password upgrades to the new PBKDF2 format.
678+
679+
Args:
680+
dry_run: If True, show what would be done but don't send emails
681+
"""
682+
from Mailman.Utils import is_old_password_format
683+
684+
print('Checking for lists with old password formats...')
685+
lists_needing_upgrade = []
686+
687+
# Check all lists for old password formats
688+
listnames = Utils.list_names()
689+
for listname in listnames:
690+
try:
691+
mlist = MailList.MailList(listname, lock=0)
692+
except Exception as e:
693+
print(C_('Warning: Could not check list %(listname)s: %(e)s') % {
694+
'listname': listname, 'e': e})
695+
continue
696+
697+
needs_upgrade = False
698+
# Check list admin password
699+
if mlist.password and is_old_password_format(mlist.password):
700+
needs_upgrade = True
701+
# Check moderator password
702+
if mlist.mod_password and is_old_password_format(mlist.mod_password):
703+
needs_upgrade = True
704+
# Check poster password
705+
if mlist.post_password and is_old_password_format(mlist.post_password):
706+
needs_upgrade = True
707+
708+
if needs_upgrade:
709+
lists_needing_upgrade.append(mlist)
710+
711+
# Check global passwords
712+
global_passwords_old = []
713+
site_pw = Utils.get_global_password(siteadmin=True)
714+
if site_pw and is_old_password_format(site_pw):
715+
global_passwords_old.append(('site admin', True))
716+
creator_pw = Utils.get_global_password(siteadmin=False)
717+
if creator_pw and is_old_password_format(creator_pw):
718+
global_passwords_old.append(('list creator', False))
719+
720+
# Send notifications if needed
721+
if lists_needing_upgrade or global_passwords_old:
722+
print(C_('Found %(count)d lists with old password formats that need upgrading.') % {
723+
'count': len(lists_needing_upgrade)})
724+
if global_passwords_old:
725+
print(C_('Also found %(count)d global password(s) with old format.') % {
726+
'count': len(global_passwords_old)})
727+
728+
# Send emails to list owners (unless dry-run)
729+
if dry_run:
730+
print(C_('DRY-RUN: Would send password upgrade notifications to %(count)d list(s).') % {
731+
'count': len(lists_needing_upgrade)})
732+
for mlist in lists_needing_upgrade:
733+
print(C_(' - Would notify owners of %(listname)s (%(listaddr)s)') % {
734+
'listname': mlist.internal_name(),
735+
'listaddr': mlist.GetListEmail()})
736+
else:
737+
for mlist in lists_needing_upgrade:
738+
send_password_upgrade_notification(mlist)
739+
740+
# Note about global passwords
741+
if global_passwords_old:
742+
print(C_('Note: Global passwords will be automatically upgraded when used.'))
743+
for pw_type, siteadmin in global_passwords_old:
744+
print(C_(' - %(pw_type)s password needs upgrade (will upgrade on next use)') % {
745+
'pw_type': pw_type})
746+
else:
747+
print('All passwords are already in the new format.')
748+
749+
750+
def send_password_upgrade_notification(mlist):
751+
"""Send an email to list administrators asking them to login to upgrade passwords.
752+
753+
Args:
754+
mlist: The MailList object for the list needing password upgrade
755+
"""
756+
# Get list owner addresses
757+
if not mlist.owner:
758+
print(C_('Warning: List %(listname)s has no owners, skipping notification.') % {
759+
'listname': mlist.internal_name()})
760+
return
761+
762+
# Set up i18n for the list's language
763+
otrans = i18n.get_translation()
764+
i18n.set_language(mlist.preferred_language)
765+
766+
try:
767+
# Create the notification message
768+
admin_url = mlist.GetScriptURL('admin', absolute=1)
769+
listname = mlist.real_name
770+
listaddr = mlist.GetListEmail()
771+
siteowner = Utils.get_site_email(mlist.host_name, 'owner')
772+
773+
text = _("""\
774+
This is an automated message from your Mailman installation.
775+
776+
Your mailing list "%(listname)s" (%(listaddr)s) is using an older password
777+
hashing format. For security reasons, we have upgraded Mailman to use a more
778+
secure password hashing method (PBKDF2-SHA256 instead of SHA1).
779+
780+
To complete the upgrade, please log in to your list administration page:
781+
782+
%(admin_url)s
783+
784+
Simply logging in with your current list administrator password will
785+
automatically upgrade your password to the new secure format. You don't
786+
need to change your password - just log in once and the upgrade will happen
787+
automatically.
788+
789+
This is a one-time upgrade. After you log in, your password will be
790+
automatically converted to the new format and you won't need to do anything
791+
else.
792+
793+
If you have any questions or concerns, please contact the site administrator
794+
at %(siteowner)s.
795+
796+
Thank you for your attention to this important security upgrade.
797+
""") % {
798+
'listname': listname,
799+
'listaddr': listaddr,
800+
'admin_url': admin_url,
801+
'siteowner': siteowner,
802+
}
803+
804+
subject = _('Action Required: Password Upgrade for %(listname)s') % {
805+
'listname': listname
806+
}
807+
808+
# Send to all list owners
809+
msg = Message.OwnerNotification(
810+
mlist, subject, text, tomoderators=0)
811+
msg.send(mlist)
812+
813+
print(C_('Sent password upgrade notification to owners of %(listname)s') % {
814+
'listname': mlist.internal_name()})
815+
except Exception as e:
816+
print(C_('Error sending notification for %(listname)s: %(e)s') % {
817+
'listname': mlist.internal_name(), 'e': e})
818+
finally:
819+
i18n.set_translation(otrans)
820+
821+
822+
823+
def main(dry_run=False):
667824
errors = 0
668825
# get rid of old stuff
669826
print('getting rid of old source files')
@@ -731,6 +888,8 @@ If your archives are big, this could take a minute or two..."""))
731888
# files from separate .msg (pickled Message objects) and .db (marshalled
732889
# dictionaries) to a shared .pck file containing two pickles.
733890
update_qfiles()
891+
# Check for old password formats and notify list admins
892+
check_and_notify_password_upgrades(dry_run=dry_run)
734893
# This warning was necessary for the upgrade from 1.0b9 to 1.0b10.
735894
# There's no good way of figuring this out for releases prior to 2.0beta2
736895
# :(
@@ -771,20 +930,23 @@ def usage(code, msg=''):
771930

772931
if __name__ == '__main__':
773932
try:
774-
opts, args = getopt.getopt(sys.argv[1:], 'hf',
775-
['help', 'force'])
933+
opts, args = getopt.getopt(sys.argv[1:], 'hfn',
934+
['help', 'force', 'dry-run'])
776935
except getopt.error as msg:
777936
usage(1, msg)
778937

779938
if args:
780939
usage(1, 'Unexpected arguments: %s' % args)
781940

782941
force = 0
942+
dry_run = 0
783943
for opt, arg in opts:
784944
if opt in ('-h', '--help'):
785945
usage(0)
786946
elif opt in ('-f', '--force'):
787947
force = 1
948+
elif opt in ('-n', '--dry-run'):
949+
dry_run = 1
788950

789951
# calculate the versions
790952
lastversion, thisversion = calcversions()
@@ -801,7 +963,9 @@ This is probably not safe.
801963
Exiting."""))
802964
sys.exit(1)
803965
print(C_('Upgrading from version %(hexlversion)s to %(hextversion)s'))
804-
errors = main()
966+
if dry_run:
967+
print(C_('DRY-RUN mode: No changes will be made, emails will not be sent.'))
968+
errors = main(dry_run=dry_run)
805969
if not errors:
806970
# Record the version we just upgraded to
807971
fp = open(LMVFILE, 'w')

0 commit comments

Comments
 (0)