@@ -11,7 +11,7 @@ from frappe.modules import make_boilerplate | |||||
from frappe.core.doctype.page.page import delete_custom_role | from frappe.core.doctype.page.page import delete_custom_role | ||||
from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles | from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles | ||||
from frappe.desk.reportview import append_totals_row | from frappe.desk.reportview import append_totals_row | ||||
from frappe.utils.safe_exec import safe_exec | |||||
from frappe.utils.safe_exec import safe_exec, check_safe_sql_query | |||||
class Report(Document): | class Report(Document): | ||||
@@ -110,15 +110,7 @@ class Report(Document): | |||||
if not self.query: | if not self.query: | ||||
frappe.throw(_("Must specify a Query to run"), title=_('Report Document Error')) | frappe.throw(_("Must specify a Query to run"), title=_('Report Document Error')) | ||||
# Disallow SQL that writes to the database. | |||||
if (not self.query.lower().startswith("select") and | |||||
not self.query.lower().startswith("with")): | |||||
frappe.throw(_("Query must be a SELECT or WITH"), title=_('Report Document Error')) | |||||
# As of MariaDB 10.9, CTE WITH statements can only be combined with a SELECT clause and | |||||
# therefore are read-only. Postgres allows WITH ... INSERT INTO statements. | |||||
if (self.query.lower().startswith("with") and frappe.db.db_type != "mariadb"): | |||||
frappe.throw(_("WITH queries are only allowed for MariaDB databases"), title=_('Report Document Error')) | |||||
check_safe_sql_query(self.query) | |||||
result = [list(t) for t in frappe.db.sql(self.query, filters)] | result = [list(t) for t in frappe.db.sql(self.query, filters)] | ||||
columns = self.get_columns() or [cstr(c[0]) for c in frappe.db.get_description()] | columns = self.get_columns() or [cstr(c[0]) for c in frappe.db.get_description()] | ||||
@@ -1,17 +1,19 @@ | |||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors | # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors | ||||
# License: MIT. See LICENSE | # License: MIT. See LICENSE | ||||
import textwrap | |||||
import frappe, json, os | import frappe, json, os | ||||
import unittest | |||||
from frappe.desk.query_report import run, save_report, add_total_row | from frappe.desk.query_report import run, save_report, add_total_row | ||||
from frappe.desk.reportview import delete_report, save_report as _save_report | from frappe.desk.reportview import delete_report, save_report as _save_report | ||||
from frappe.custom.doctype.customize_form.customize_form import reset_customization | from frappe.custom.doctype.customize_form.customize_form import reset_customization | ||||
from frappe.core.doctype.user_permission.test_user_permission import create_user | from frappe.core.doctype.user_permission.test_user_permission import create_user | ||||
from frappe.tests.utils import FrappeTestCase | |||||
test_records = frappe.get_test_records('Report') | test_records = frappe.get_test_records('Report') | ||||
test_dependencies = ['User'] | test_dependencies = ['User'] | ||||
class TestReport(unittest.TestCase): | |||||
class TestReport(FrappeTestCase): | |||||
def test_report_builder(self): | def test_report_builder(self): | ||||
if frappe.db.exists('Report', 'User Activity Report'): | if frappe.db.exists('Report', 'User Activity Report'): | ||||
frappe.delete_doc('Report', 'User Activity Report') | frappe.delete_doc('Report', 'User Activity Report') | ||||
@@ -335,3 +337,29 @@ result = [ | |||||
self.assertEqual(result[-1][0], "Total") | self.assertEqual(result[-1][0], "Total") | ||||
self.assertEqual(result[-1][1], 200) | self.assertEqual(result[-1][1], 200) | ||||
self.assertEqual(result[-1][2], 150.50) | self.assertEqual(result[-1][2], 150.50) | ||||
def test_cte_in_query_report(self): | |||||
cte_query = textwrap.dedent(""" | |||||
with enabled_users as ( | |||||
select name | |||||
from `tabUser` | |||||
where enabled = 1 | |||||
) | |||||
select * from enabled_users; | |||||
""") | |||||
report = frappe.get_doc({ | |||||
"doctype": "Report", | |||||
"ref_doctype": "User", | |||||
"report_name": "Enabled Users List", | |||||
"report_type": "Query Report", | |||||
"is_standard": "No", | |||||
"query": cte_query, | |||||
}).insert() | |||||
if frappe.db.db_type == "mariadb": | |||||
col, rows = report.execute_query_report(filters={}) | |||||
self.assertEqual(col[0], "name") | |||||
self.assertGreaterEqual(len(rows), 1) | |||||
elif frappe.db.db_type == "postgres": | |||||
self.assertRaises(frappe.PermissionError, report.execute_query_report, filters={}) |
@@ -54,6 +54,9 @@ def patch_query_execute(): | |||||
This excludes the use of `frappe.db.sql` method while | This excludes the use of `frappe.db.sql` method while | ||||
executing the query object | executing the query object | ||||
""" | """ | ||||
from frappe.utils.safe_exec import check_safe_sql_query | |||||
def execute_query(query, *args, **kwargs): | def execute_query(query, *args, **kwargs): | ||||
query, params = prepare_query(query) | query, params = prepare_query(query) | ||||
return frappe.db.sql(query, params, *args, **kwargs) # nosemgrep | return frappe.db.sql(query, params, *args, **kwargs) # nosemgrep | ||||
@@ -63,7 +66,7 @@ def patch_query_execute(): | |||||
param_collector = NamedParameterWrapper() | param_collector = NamedParameterWrapper() | ||||
query = query.get_sql(param_wrapper=param_collector) | query = query.get_sql(param_wrapper=param_collector) | ||||
if frappe.flags.in_safe_exec and not query.lower().strip().startswith("select"): | |||||
if frappe.flags.in_safe_exec and not check_safe_sql_query(query, throw=False): | |||||
callstack = inspect.stack() | callstack = inspect.stack() | ||||
if len(callstack) >= 3 and ".py" in callstack[2].filename: | if len(callstack) >= 3 and ".py" in callstack[2].filename: | ||||
# ignore any query builder methods called from python files | # ignore any query builder methods called from python files | ||||
@@ -77,7 +80,7 @@ def patch_query_execute(): | |||||
# | # | ||||
# if frame2 is server script it wont have a filename and hence | # if frame2 is server script it wont have a filename and hence | ||||
# it shouldn't be allowed. | # it shouldn't be allowed. | ||||
# ps. stack() returns `"<unknown>"` as filename. | |||||
# p.s. stack() returns `"<unknown>"` as filename if not a file. | |||||
pass | pass | ||||
else: | else: | ||||
raise frappe.PermissionError('Only SELECT SQL allowed in scripting') | raise frappe.PermissionError('Only SELECT SQL allowed in scripting') | ||||
@@ -269,11 +269,33 @@ def get_hooks(hook=None, default=None, app_name=None): | |||||
def read_sql(query, *args, **kwargs): | def read_sql(query, *args, **kwargs): | ||||
'''a wrapper for frappe.db.sql to allow reads''' | '''a wrapper for frappe.db.sql to allow reads''' | ||||
query = str(query) | query = str(query) | ||||
if frappe.flags.in_safe_exec and not query.strip().lower().startswith('select'): | |||||
raise frappe.PermissionError('Only SELECT SQL allowed in scripting') | |||||
if frappe.flags.in_safe_exec: | |||||
check_safe_sql_query(query) | |||||
return frappe.db.sql(query, *args, **kwargs) | return frappe.db.sql(query, *args, **kwargs) | ||||
def check_safe_sql_query(query: str, throw: bool = True) -> bool: | |||||
""" Check if SQL query is safe for running in restricted context. | |||||
Safe queries: | |||||
1. Read only 'select' or 'explain' queries | |||||
2. CTE on mariadb where writes are not allowed. | |||||
""" | |||||
query = query.strip().lower() | |||||
whitelisted_statements = ("select", "explain") | |||||
if (query.startswith(whitelisted_statements) | |||||
or (query.startswith("with") and frappe.db.db_type == "mariadb")): | |||||
return True | |||||
if throw: | |||||
frappe.throw(_("Query must be of SELECT or read-only WITH type."), | |||||
title=_("Unsafe SQL query"), exc=frappe.PermissionError) | |||||
return False | |||||
def _getitem(obj, key): | def _getitem(obj, key): | ||||
# guard function for RestrictedPython | # guard function for RestrictedPython | ||||
# allow any key to be accessed as long as it does not start with underscore | # allow any key to be accessed as long as it does not start with underscore | ||||