From fd40eef2d3220bb0a56d8e3d4da57bc4460d859f Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Thu, 26 Feb 2026 10:45:47 +0530 Subject: [PATCH 1/5] fix(user): send mail to user to indicate that their password has been updated Send an e-mail to user to indicate that their password has been changed, fixes a security flaw where user would just be logged out and have no clue as to what occurred Co-authored-by: Ankush Menat --- frappe/core/doctype/user/user.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index b1831d59f9..8e65573474 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -376,9 +376,23 @@ class User(Document): toggle_notifications(self.name, enable=cint(self.enabled), ignore_permissions=True) self.disable_email_fields_if_user_disabled() - def email_new_password(self, new_password=None): + def set_new_password(self, new_password=None): + """Set New Password for user""" if new_password and not self.flags.in_insert: _update_password(user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions) + outgoing_email_exists = frappe.db.exists( + "Email Account", {"default_outgoing": 1, "awaiting_password": 0} + ) + if outgoing_email_exists: + email_message = _( + "Your password has been changed and you might have been logged out of all systems.
Please contact the Administrator for further assistance." + ) + user_email = frappe.db.get_value("User", self.name, "email") + frappe.sendmail( + recipients=[user_email], + subject=_("Security Alert: Your password has been changed."), + content=email_message, + ) def set_system_user(self): """For the standard users like admin and guest, the user type is fixed.""" @@ -451,7 +465,7 @@ class User(Document): msgprint(_("Welcome email sent")) return else: - self.email_new_password(new_password) + self.set_new_password(new_password) except frappe.OutgoingEmailError: frappe.clear_last_message() From 503150f99f2874bbe851094f6975cee874235e17 Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Mon, 2 Mar 2026 11:17:09 +0530 Subject: [PATCH 2/5] refactor(user): cleaner code in send_password_notification Small refactor for cleaner code. Co-authored-by: Ankush Menat --- frappe/core/doctype/user/user.py | 33 ++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 8e65573474..fdc3cca83c 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -447,23 +447,24 @@ class User(Document): def send_password_notification(self, new_password): try: if self.flags.in_insert: - if self.name not in STANDARD_USERS: - if new_password: - # new password given, no email required - _update_password( - user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions - ) + if self.name in STANDARD_USERS: + return + if new_password: + # new password given, no email required + _update_password( + user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions + ) - if ( - not self.flags.no_welcome_mail - and cint(self.send_welcome_email) - and not self.flags.email_sent - ): - self.send_welcome_mail_to_user() - self.flags.email_sent = 1 - if frappe.session.user != "Guest": - msgprint(_("Welcome email sent")) - return + if ( + not self.flags.no_welcome_mail + and cint(self.send_welcome_email) + and not self.flags.email_sent + ): + self.send_welcome_mail_to_user() + self.flags.email_sent = 1 + if frappe.session.user != "Guest": + msgprint(_("Welcome email sent")) + return else: self.set_new_password(new_password) From 6885bf8a642bdd0cec52a9c351400d3a3571ee4d Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Mon, 9 Mar 2026 12:08:08 +0530 Subject: [PATCH 3/5] refactor: return link only when used internally Restrict _reset_password() for internal use. Return link when used as an internal func, whitelisted method to be used otherwise, when resetting password. Co-authored-by: Ankush Menat --- frappe/auth.py | 4 +++- frappe/core/api/user_invitation.py | 2 +- frappe/core/doctype/user/test_user.py | 2 +- frappe/core/doctype/user/user.py | 6 +++--- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/frappe/auth.py b/frappe/auth.py index 1658930317..d4a0dae4a0 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -155,7 +155,9 @@ class LoginManager: self.authenticate(user=user, pwd=pwd) if self.force_user_to_reset_password(): doc = frappe.get_doc("User", self.user) - frappe.local.response["redirect_to"] = doc.reset_password(send_email=False, password_expired=True) + frappe.local.response["redirect_to"] = doc._reset_password( + send_email=False, password_expired=True + ) frappe.local.response["message"] = "Password Reset" return False diff --git a/frappe/core/api/user_invitation.py b/frappe/core/api/user_invitation.py index 7ca304b046..619b8e1af8 100644 --- a/frappe/core/api/user_invitation.py +++ b/frappe/core/api/user_invitation.py @@ -126,7 +126,7 @@ def _accept_invitation(key: str, in_test: bool) -> None: # set redirect_to redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path()) if should_update_password: - redirect_to = f"{user.reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}" + redirect_to = f"{user._reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}" # GET requests do not cause an implicit commit frappe.db.commit() # nosemgrep diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index aa7c182b00..691c8235a1 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -42,7 +42,7 @@ class TestUser(IntegrationTestCase): @staticmethod def reset_password(user) -> str: - link = user.reset_password() + link = user._reset_password() return parse_qs(urlparse(link).query)["key"][0] def test_user_type(self): diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index fdc3cca83c..5e646f8650 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -480,7 +480,7 @@ class User(Document): def validate_reset_password(self): pass - def reset_password(self, send_email=False, password_expired=False): + def _reset_password(self, send_email=False, password_expired=False): from frappe.utils import get_url key = frappe.generate_hash() @@ -516,7 +516,7 @@ class User(Document): def send_welcome_mail_to_user(self): from frappe.utils import get_url - link = self.reset_password() + link = self._reset_password() subject = None method = frappe.get_hooks("welcome_email") if method: @@ -1142,7 +1142,7 @@ def reset_password(user: str) -> str: return "disabled" user.validate_reset_password() - user.reset_password(send_email=True) + user._reset_password(send_email=True) return frappe.msgprint( msg=_("Password reset instructions have been sent to {}'s email").format(user.full_name), From 391fcdb1cb72da3f20e11f0edc118174c2de6ab6 Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Mon, 9 Mar 2026 16:42:23 +0530 Subject: [PATCH 4/5] fix: strip sensitive content from being displayed in email queue Strip sensitive info. like reset password link... from the email queue but retain crucial info. like email headers Co-authored-by: Ankush Menat --- frappe/core/doctype/user/user.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 5e646f8650..74e71edc1e 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -1,6 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import re from collections.abc import Iterable from datetime import timedelta from functools import cached_property @@ -505,13 +506,19 @@ class User(Document): def password_reset_mail(self, link): reset_password_template = frappe.db.get_system_setting("reset_password_template") - self.send_login_mail( + q = self.send_login_mail( _("Password Reset"), "password_reset", {"link": link}, now=True, custom_template=reset_password_template, ) + if q: + raw_message = q.message + parts = re.split(r"(?i)Dear", raw_message, maxsplit=1) + if len(parts) > 1: + redacted_message = parts[0] + "[THE FOLLOWING CONTENT HAS BEEN REDACTED FOR SECURITY REASONS]" + frappe.db.set_value("Email Queue", q.name, "message", redacted_message, update_modified=False) def send_welcome_mail_to_user(self): from frappe.utils import get_url @@ -530,7 +537,7 @@ class User(Document): welcome_email_template = frappe.db.get_system_setting("welcome_email_template") - self.send_login_mail( + q = self.send_login_mail( subject, "new_user", dict( @@ -539,6 +546,12 @@ class User(Document): ), custom_template=welcome_email_template, ) + if q: + raw_message = q.message + parts = re.split(r"(?i)Hello", raw_message, maxsplit=1) + if len(parts) > 1: + redacted_message = parts[0] + "[THE FOLLOWING CONTENT HAS BEEN REDACTED FOR SECURITY REASONS]" + frappe.db.set_value("Email Queue", q.name, "message", redacted_message, update_modified=False) def send_login_mail(self, subject, template, add_args, now=None, custom_template=None): """send mail with login details""" @@ -570,7 +583,7 @@ class User(Document): subject = email_template.get("subject") content = email_template.get("message") - frappe.sendmail( + return frappe.sendmail( recipients=self.email, sender=sender, subject=subject, From 3624bc6e43f7c47c973e0d54de7d1d3a26796d9b Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Mon, 9 Mar 2026 17:08:19 +0530 Subject: [PATCH 5/5] test: rewrite test based on new changes Co-authored-by: Ankush Menat --- frappe/core/doctype/user/test_user.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 691c8235a1..62cba4c54c 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -415,6 +415,12 @@ class TestUser(IntegrationTestCase): # test API endpoint with patch.object(user_module.frappe, "sendmail") as sendmail: + from unittest.mock import MagicMock + + mock_q = MagicMock() + mock_q.name = "test-email-queue-name" + mock_q.message = "Subject: Test\n\nDear User, here is your link" + sendmail.return_value = mock_q frappe.clear_messages() test_user = frappe.get_doc("User", "test2@example.com") self.assertEqual(reset_password(user="test2@example.com"), None)