CVE-2022-23058 (published 2022-06-22T08:15Z)

ERPNext in versions v12.0.9-v13.0.3 are affected by a stored XSS vulnerability that allows low privileged users to store malicious scripts in the ‘username’ field in ‘my settings’ which can lead to full account takeover.

Severity: LOW
CVSS v2 base score: 3.5 (AV:N/AC:M/Au:S/C:N/I:P/A:N)

Weakness information:
cwe_id: CWE-79
cwe_name: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
extended_description: 
            Cross-site scripting (XSS) vulnerabilities occur when:
            
               Untrusted data enters a web application, typically from a web request.
               The web application dynamically generates a web page that contains this untrusted data.
               During page generation, the application does not prevent the data from containing content that is executable by a web browser, such as JavaScript, HTML tags, HTML attributes, mouse events, Flash, ActiveX, etc.
               A victim visits the generated web page through a web browser, which contains malicious script that was injected using the untrusted data.
               Since the script comes from a web page that was sent by the web server, the victim's web browser executes the malicious script in the context of the web server's domain.
               This effectively violates the intention of the web browser's same-origin policy, which states that scripts in one domain should not be able to access resources or run code in a different domain.
            
            There are three main kinds of XSS:
            
               
                  Type 1: Reflected XSS (or Non-Persistent) - 
         			The server reads data directly from the HTTP request and reflects it back in the HTTP response. Reflected XSS exploits occur when an attacker causes a victim to supply dangerous content to a vulnerable web application, which is then reflected back to the victim and executed by the web browser. The most common mechanism for delivering malicious content is to include it as a parameter in a URL that is posted publicly or e-mailed directly to the victim. URLs constructed in this manner constitute the core of many phishing schemes, whereby an attacker convinces a victim to visit a URL that refers to a vulnerable site. After the site reflects the attacker's content back to the victim, the content is executed by the victim's browser.
               
                  Type 2: Stored XSS (or Persistent) - 
               The application stores dangerous data in a database, message forum, visitor log, or other trusted data store. At a later time, the dangerous data is subsequently read back into the application and included in dynamic content. From an attacker's perspective, the optimal place to inject malicious content is in an area that is displayed to either many users or particularly interesting users. Interesting users typically have elevated privileges in the application or interact with sensitive data that is valuable to the attacker. If one of these users executes malicious content, the attacker may be able to perform privileged operations on behalf of the user or gain access to sensitive data belonging to the user. For example, the attacker might inject XSS into a log message, which might not be handled properly when an administrator views the logs.
            
               
                  Type 0: DOM-Based XSS - 
               In DOM-based XSS, the client performs the injection of XSS into the page; in the other types, the server performs the injection. DOM-based XSS generally involves server-controlled, trusted script that is sent to the client, such as Javascript that performs sanity checks on a form before the user submits it. If the server-supplied script processes user-supplied data and then injects it back into the web page (such as with dynamic HTML), then DOM-based XSS is possible.
            
            
            Once the malicious script is injected, the attacker can perform a variety of malicious activities. The attacker could transfer private information, such as cookies that may include session information, from the victim's machine to the attacker. The attacker could send malicious requests to a web site on behalf of the victim, which could be especially dangerous to the site if the victim has administrator privileges to manage that site. Phishing attacks could be used to emulate trusted web sites and trick the victim into entering a password, allowing the attacker to compromise the victim's account on that web site. Finally, the script could exploit a vulnerability in the web browser itself possibly taking over the victim's machine, sometimes referred to as "drive-by hacking."
            In many cases, the attack can be launched without the victim even being aware of it. Even with careful users, attackers frequently use a variety of methods to encode the malicious portion of the attack, such as URL encoding or Unicode, so the request looks less suspicious.
         
         

Fix information:
repo_url: https://github.com/frappe/frappe
commit_details: repo_url: https://github.com/frappe/frappe
author: Sagar Vora
author_date: 2021-03-30 12:52:14+05:30
msg: feat: frappe.whitelist for class methods
repository: repo_url: https://github.com/frappe/frappe
repo_name: frappe/frappe
description: Low code web framework for real world applications, in Python and Javascript
repo_language: Python
owner: frappe
file_changes: filename: __init__.py
change_type: ModificationType.MODIFY
diff: @@ -555,8 +555,13 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
 
 	def innerfn(fn):
 		global whitelisted, guest_methods, xss_safe_methods, allowed_http_methods_for_whitelisted_func
-		whitelisted.append(fn)
 
+		# get function from the unbound / bound method
+		# this is needed because functions can be compared, but not methods
+		if hasattr(fn, '__func__'):
+			fn = fn.__func__
+
+		whitelisted.append(fn)
 		allowed_http_methods_for_whitelisted_func[fn] = methods
 
 		if allow_guest:
@@ -569,6 +574,20 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
 
 	return innerfn
 
+def is_whitelisted(method):
+	from frappe.utils import sanitize_html
+
+	is_guest = session['user'] == 'Guest'
+	if method not in whitelisted or is_guest and method not in guest_methods:
+		throw(_("Not permitted"), PermissionError)
+
+	if is_guest and method not in xss_safe_methods:
+		# strictly sanitize form_dict
+		# escapes html characters like <> except for predefined tags like a, b, ul etc.
+		for key, value in form_dict.items():
+			if isinstance(value, string_types):
+				form_dict[key] = sanitize_html(value)
+
 def read_only():
 	def innfn(fn):
 		def wrapper_fn(*args, **kwargs):

programming_language: Python
filename: auto_repeat.py
change_type: ModificationType.MODIFY
diff: @@ -118,6 +118,7 @@ class AutoRepeat(Document):
 	def is_completed(self):
 		return self.end_date and getdate(self.end_date) < getdate(today())
 
+	@frappe.whitelist()
 	def get_auto_repeat_schedule(self):
 		schedule_details = []
 		start_date = getdate(self.start_date)
@@ -328,6 +329,7 @@ class AutoRepeat(Document):
 		make(doctype=new_doc.doctype, name=new_doc.name, recipients=recipients,
 			subject=subject, content=message, attachments=attachments, send_email=1)
 
+	@frappe.whitelist()
 	def fetch_linked_contacts(self):
 		if self.reference_doctype and self.reference_document:
 			res = get_contacts_linking_to(self.reference_doctype, self.reference_document, fields=['email_id'])

programming_language: Python
filename: report.py
change_type: ModificationType.MODIFY
diff: @@ -58,6 +58,7 @@ class Report(Document):
 	def get_columns(self):
 		return [d.as_dict(no_default_fields = True) for d in self.columns]
 
+	@frappe.whitelist()
 	def set_doctype_roles(self):
 		if not self.get('roles') and self.is_standard == 'No':
 			meta = frappe.get_meta(self.ref_doctype)
@@ -304,7 +305,7 @@ class Report(Document):
 
 		return data
 
-	@Document.whitelist
+	@frappe.whitelist()
 	def toggle_disable(self, disable):
 		self.db_set("disabled", cint(disable))
 

programming_language: Python
filename: role_permission_for_page_and_report.py
change_type: ModificationType.MODIFY
diff: @@ -8,6 +8,7 @@ from frappe.core.doctype.report.report import is_prepared_report_disabled
 from frappe.model.document import Document
 
 class RolePermissionforPageandReport(Document):
+	@frappe.whitelist()
 	def set_report_page_data(self):
 		self.set_custom_roles()
 		self.check_prepared_report_disabled()
@@ -35,12 +36,14 @@ class RolePermissionforPageandReport(Document):
 		doc = frappe.get_doc(doctype, docname)
 		return doc.roles
 
+	@frappe.whitelist()
 	def reset_roles(self):
 		roles = self.get_standard_roles()
 		self.set('roles', roles)
 		self.update_custom_roles()
 		self.update_disable_prepared_report()
 
+	@frappe.whitelist()
 	def update_report_page_data(self):
 		self.update_custom_roles()
 		self.update_disable_prepared_report()

programming_language: Python
filename: customize_form.py
change_type: ModificationType.MODIFY
diff: @@ -24,6 +24,7 @@ class CustomizeForm(Document):
 		frappe.db.sql("delete from tabSingles where doctype='Customize Form'")
 		frappe.db.sql("delete from `tabCustomize Form Field`")
 
+	@frappe.whitelist()
 	def fetch_to_customize(self):
 		self.clear_existing_doc()
 		if not self.doc_type:
@@ -133,6 +134,7 @@ class CustomizeForm(Document):
 		self.doc_type = doc_type
 		self.name = "Customize Form"
 
+	@frappe.whitelist()
 	def save_customization(self):
 		if not self.doc_type:
 			return
@@ -448,6 +450,7 @@ class CustomizeForm(Document):
 
 		self.flags.update_db = True
 
+	@frappe.whitelist()
 	def reset_to_defaults(self):
 		if not self.doc_type:
 			return

programming_language: Python
filename: data_migration_run.py
change_type: ModificationType.MODIFY
diff: @@ -10,6 +10,7 @@ from frappe.utils import cstr
 from frappe.data_migration.doctype.data_migration_mapping.data_migration_mapping import get_source_value
 
 class DataMigrationRun(Document):
+	@frappe.whitelist()
 	def run(self):
 		self.begin()
 		if self.total_pages > 0:

programming_language: Python
filename: run_method.py
change_type: ModificationType.DELETE
diff: @@ -1,81 +0,0 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
-import json, inspect
-import frappe
-from frappe import _
-from frappe.utils import cint
-from six import text_type, string_types
-
-@frappe.whitelist()
-def runserverobj(method, docs=None, dt=None, dn=None, arg=None, args=None):
-	"""run controller method - old style"""
-	if not args: args = arg or ""
-
-	if dt: # not called from a doctype (from a page)
-		if not dn: dn = dt # single
-		doc = frappe.get_doc(dt, dn)
-
-	else:
-		doc = frappe.get_doc(json.loads(docs))
-		doc._original_modified = doc.modified
-		doc.check_if_latest()
-
-	if not doc.has_permission("read"):
-		frappe.msgprint(_("Not permitted"), raise_exception = True)
-
-	if doc:
-		try:
-			args = json.loads(args)
-		except ValueError:
-			args = args
-
-		try:
-			fnargs, varargs, varkw, defaults = inspect.getargspec(getattr(doc, method))
-		except ValueError:
-			fnargs = inspect.getfullargspec(getattr(doc, method)).args
-			varargs = inspect.getfullargspec(getattr(doc, method)).varargs
-			varkw = inspect.getfullargspec(getattr(doc, method)).varkw
-			defaults = inspect.getfullargspec(getattr(doc, method)).defaults
-
-		if not fnargs or (len(fnargs)==1 and fnargs[0]=="self"):
-			r = doc.run_method(method)
-
-		elif "args" in fnargs or not isinstance(args, dict):
-			r = doc.run_method(method, args)
-
-		else:
-			r = doc.run_method(method, **args)
-
-		if r:
-			#build output as csv
-			if cint(frappe.form_dict.get('as_csv')):
-				make_csv_output(r, doc.doctype)
-			else:
-				frappe.response['message'] = r
-
-		frappe.response.docs.append(doc)
-
-def make_csv_output(res, dt):
-	"""send method response as downloadable CSV file"""
-	import frappe
-
-	from six import StringIO
-	import csv
-
-	f = StringIO()
-	writer = csv.writer(f)
-	for r in res:
-		row = []
-		for v in r:
-			if isinstance(v, string_types):
-				v = v.encode("utf-8")
-			row.append(v)
-		writer.writerow(row)
-
-	f.seek(0)
-
-	frappe.response['result'] = text_type(f.read(), 'utf-8')
-	frappe.response['type'] = 'csv'
-	frappe.response['doctype'] = dt.replace(' ','')

programming_language: unknown
filename: search.py
change_type: ModificationType.MODIFY
diff: @@ -6,8 +6,7 @@ from __future__ import unicode_literals
 import frappe, json
 from frappe.utils import cstr, unique, cint
 from frappe.permissions import has_permission
-from frappe.handler import is_whitelisted
-from frappe import _
+from frappe import _, is_whitelisted
 from six import string_types
 import re
 import wrapt
@@ -221,4 +220,4 @@ def validate_and_sanitize_search_inputs(fn, instance, args, kwargs):
 	if kwargs['doctype'] and not frappe.db.exists('DocType', kwargs['doctype']):
 		return []
 
-	return fn(**kwargs)
\ No newline at end of file
+	return fn(**kwargs)

programming_language: Python
filename: newsletter.py
change_type: ModificationType.MODIFY
diff: @@ -29,6 +29,7 @@ class Newsletter(WebsiteGenerator):
 		self.queue_all(test_email=True)
 		frappe.msgprint(_("Test email sent to {0}").format(self.test_email_id))
 
+	@frappe.whitelist()
 	def send_emails(self):
 		"""send emails to leads and customers"""
 		if self.email_sent:

programming_language: Python
filename: handler.py
change_type: ModificationType.MODIFY
diff: @@ -2,17 +2,22 @@
 # MIT License. See license.txt
 
 from __future__ import unicode_literals
+
+from werkzeug.wrappers import Response
+from six import text_type, string_types, StringIO
+
 import frappe
-from frappe import _
 import frappe.utils
 import frappe.sessions
 import frappe.desk.form.run_method
-from frappe.utils.response import build_response
-from frappe.api import validate_auth
+
 from frappe.utils import cint
+from frappe.api import validate_auth
+from frappe import _, is_whitelisted
+from frappe.utils.response import build_response
+from frappe.utils.csvutils import build_csv_response
 from frappe.core.doctype.server_script.server_script_utils import run_server_script_api
-from werkzeug.wrappers import Response
-from six import string_types
+
 
 ALLOWED_MIMETYPES = ('image/png', 'image/jpeg', 'application/pdf', 'application/msword',
 			'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
@@ -64,8 +69,9 @@ def execute_cmd(cmd, from_async=False):
 	if from_async:
 		method = method.queue
 
-	is_whitelisted(method)
-	is_valid_http_method(method)
+	if method != run_doc_method:
+		is_whitelisted(method)
+		is_valid_http_method(method)
 
 	return frappe.call(method, **frappe.form_dict)
 
@@ -75,31 +81,10 @@ def is_valid_http_method(method):
 	if http_method not in frappe.allowed_http_methods_for_whitelisted_func[method]:
 		frappe.throw(_("Not permitted"), frappe.PermissionError)
 
-def is_whitelisted(method):
-	# check if whitelisted
-	if frappe.session['user'] == 'Guest':
-		if (method not in frappe.guest_methods):
-			frappe.throw(_("Not permitted"), frappe.PermissionError)
-
-		if method not in frappe.xss_safe_methods:
-			# strictly sanitize form_dict
-			# escapes html characters like <> except for predefined tags like a, b, ul etc.
-			for key, value in frappe.form_dict.items():
-				if isinstance(value, string_types):
-					frappe.form_dict[key] = frappe.utils.sanitize_html(value)
-
-	else:
-		if not method in frappe.whitelisted:
-			frappe.throw(_("Not permitted"), frappe.PermissionError)
-
 @frappe.whitelist(allow_guest=True)
 def version():
 	return frappe.__version__
 
-@frappe.whitelist()
-def runserverobj(method, docs=None, dt=None, dn=None, arg=None, args=None):
-	frappe.desk.form.run_method.runserverobj(method, docs=docs, dt=dt, dn=dn, arg=arg, args=args)
-
 @frappe.whitelist(allow_guest=True)
 def logout():
 	frappe.local.login_manager.logout()
@@ -112,15 +97,6 @@ def web_logout():
 	frappe.respond_as_web_page(_("Logged Out"), _("You have been successfully logged out"),
 		indicator_color='green')
 
-@frappe.whitelist(allow_guest=True)
-def run_custom_method(doctype, name, custom_method):
-	"""cmd=run_custom_method&doctype={doctype}&name={name}&custom_method={custom_method}"""
-	doc = frappe.get_doc(doctype, name)
-	if getattr(doc, custom_method, frappe._dict()).is_whitelisted:
-		frappe.call(getattr(doc, custom_method), **frappe.local.form_dict)
-	else:
-		frappe.throw(_("Not permitted"), frappe.PermissionError)
-
 @frappe.whitelist()
 def uploadfile():
 	ret = None
@@ -222,6 +198,65 @@ def get_attr(cmd):
 	frappe.log("method:" + cmd)
 	return method
 
-@frappe.whitelist(allow_guest = True)
+@frappe.whitelist(allow_guest=True)
 def ping():
 	return "pong"
+
+@frappe.whitelist()
+def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
+	"""run controller method - old style"""
+	import json, inspect
+
+	if not args: args = arg or ""
+
+	if dt: # not called from a doctype (from a page)
+		if not dn: dn = dt # single
+		doc = frappe.get_doc(dt, dn)
+
+	else:
+		doc = frappe.get_doc(json.loads(docs))
+		doc._original_modified = doc.modified
+		doc.check_if_latest()
+
+	if not doc.has_permission("read"):
+		frappe.msgprint(_("Not permitted"), raise_exception = True)
+
+	if not doc:
+		return
+
+	try:
+		args = json.loads(args)
+	except ValueError:
+		args = args
+
+	method_obj = getattr(doc, method)
+	is_whitelisted(getattr(method_obj, '__func__', method_obj))
+
+	try:
+		fnargs = inspect.getargspec(method_obj)[0]
+	except ValueError:
+		fnargs = inspect.getfullargspec(method_obj).args
+
+	if not fnargs or (len(fnargs)==1 and fnargs[0]=="self"):
+		r = doc.run_method(method)
+
+	elif "args" in fnargs or not isinstance(args, dict):
+		r = doc.run_method(method, args)
+
+	else:
+		r = doc.run_method(method, **args)
+
+	frappe.response.docs.append(doc)
+
+	if not r:
+		return
+
+	# build output as csv
+	if cint(frappe.form_dict.get('as_csv')):
+		build_csv_response(r, doc.doctype.replace(' ', ''))
+		return
+
+	frappe.response['message'] = r
+
+# for backwards compatibility
+runserverobj = run_doc_method

programming_language: Python
filename: document.py
change_type: ModificationType.MODIFY
diff: @@ -4,7 +4,7 @@
 from __future__ import unicode_literals, print_function
 import frappe
 import time
-from frappe import _, msgprint
+from frappe import _, msgprint, is_whitelisted
 from frappe.utils import flt, cstr, now, get_datetime_str, file_lock, date_diff
 from frappe.model.base_document import BaseDocument, get_controller
 from frappe.model.naming import set_new_name
@@ -126,10 +126,10 @@ class Document(BaseDocument):
 			raise ValueError('Illegal arguments')
 
 	@staticmethod
-	def whitelist(f):
+	def whitelist(fn):
 		"""Decorator: Whitelist method to be called remotely via REST API."""
-		f.whitelisted = True
-		return f
+		frappe.whitelist()(fn)
+		return fn
 
 	def reload(self):
 		"""Reload document from database"""
@@ -1148,12 +1148,12 @@ class Document(BaseDocument):
 
 		return composer
 
-	def is_whitelisted(self, method):
-		fn = getattr(self, method, None)
+	def is_whitelisted(self, method_name):
+		method = getattr(self, method_name, None)
 		if not fn:
-			raise NotFound("Method {0} not found".format(method))
-		elif not getattr(fn, "whitelisted", False):
-			raise Forbidden("Method {0} not whitelisted".format(method))
+			raise NotFound("Method {0} not found".format(method_name))
+
+		is_whitelisted(getattr(method, '__func__', method))
 
 	def validate_value(self, fieldname, condition, val2, doc=None, raise_exception=None):
 		"""Check that value of fieldname should be 'condition' val2

programming_language: Python
filename: button.js
change_type: ModificationType.MODIFY
diff: @@ -34,7 +34,7 @@ frappe.ui.form.ControlButton = frappe.ui.form.ControlData.extend({
 		var me = this;
 		if(this.frm && this.frm.docname) {
 			frappe.call({
-				method: "runserverobj",
+				method: "run_doc_method",
 				args: {'docs': this.frm.doc, 'method': this.df.options },
 				btn: this.$input,
 				callback: function(r) {

programming_language: JavaScript
filename: request.js
change_type: ModificationType.MODIFY
diff: @@ -55,7 +55,7 @@ frappe.call = function(opts) {
 		args.cmd = opts.module+'.page.'+opts.page+'.'+opts.page+'.'+opts.method;
 	} else if(opts.doc) {
 		$.extend(args, {
-			cmd: "runserverobj",
+			cmd: "run_doc_method",
 			docs: frappe.get_doc(opts.doc.doctype, opts.doc.name),
 			method: opts.method,
 			args: opts.args,

programming_language: JavaScript
filename: energy_point_log.py
change_type: ModificationType.MODIFY
diff: @@ -52,6 +52,7 @@ class EnergyPointLog(Document):
 			reference_log.reverted = 0
 			reference_log.save()
 
+	@frappe.whitelist()
 	def revert(self, reason, ignore_permissions=False):
 		if not ignore_permissions:
 			frappe.only_for('System Manager')

programming_language: Python
filename: blog_post.py
change_type: ModificationType.MODIFY
diff: @@ -18,6 +18,7 @@ class BlogPost(WebsiteGenerator):
 		order_by = "published_on desc"
 	)
 
+	@frappe.whitelist()
 	def make_route(self):
 		if not self.route:
 			return frappe.db.get_value('Blog Category', self.blog_category,

programming_language: Python
filename: portal_settings.py
change_type: ModificationType.MODIFY
diff: @@ -19,6 +19,7 @@ class PortalSettings(Document):
 			self.append('menu', item)
 			return True
 
+	@frappe.whitelist()
 	def reset(self):
 		'''Restore defaults'''
 		self.menu = []

programming_language: Python
filename: website_theme.py
change_type: ModificationType.MODIFY
diff: @@ -98,6 +98,7 @@ class WebsiteTheme(Document):
 		else:
 			self.generate_bootstrap_theme()
 
+	@frappe.whitelist()
 	def set_as_default(self):
 		self.generate_bootstrap_theme()
 		self.save()
@@ -106,6 +107,7 @@ class WebsiteTheme(Document):
 		website_settings.ignore_validate = True
 		website_settings.save()
 
+	@frappe.whitelist()
 	def get_apps(self):
 		from frappe.utils.change_log import get_versions
 		apps = get_versions()

programming_language: Python