fix(accounts): enforce account field allow-list on financial report filters (backport #58790) (#58849)

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
This commit is contained in:
mergify[bot]
2026-09-08 11:58:51 +05:30
committed by GitHub
parent cfdf97601a
commit 6e39b42137
3 changed files with 117 additions and 13 deletions

View File

@@ -479,7 +479,10 @@ class DataCollector:
if company:
query = query.where(account.company == company)
if conditions := filter_parser.build_conditions(account_rows, account):
# filters are optional: no filter means all (enabled, non-group) accounts of the company.
# invalid filters can't reach here — build_conditions raises on them (raise_on_invalid).
conditions = filter_parser.build_conditions(account_rows, account, raise_on_invalid=True)
if conditions is not None:
query = query.where(conditions)
return query.run(pluck=True)
@@ -791,17 +794,20 @@ class FilterExpressionParser:
def __init__(self):
self.validator = AccountFilterValidator()
def build_conditions(self, report_rows, table):
def build_conditions(self, report_rows, table, raise_on_invalid=False):
conditions = []
for row in report_rows or []:
condition = self.build_condition(row, table)
condition = self.build_condition(row, table, raise_on_invalid=raise_on_invalid)
if condition is not None:
conditions.append(condition)
if not conditions:
return None
# ensure brackets in or condition
return reduce(lambda a, b: (a) | (b), conditions)
def build_condition(self, report_row, table):
def build_condition(self, report_row, table, raise_on_invalid=False):
"""
Build SQL condition directly from filter formula.
@@ -831,9 +837,11 @@ class FilterExpressionParser:
if not filter_formula:
return None
errors = self.validator.validate(report_row)
errors = self.validator.validate_filter(report_row)
if not errors.is_valid:
error_messages = [str(issue) for issue in errors.issues]
if raise_on_invalid:
frappe.throw("<br><br>".join(error_messages), title=_("Invalid Filter"))
frappe.log_error(f"Filter validation errors found:\n{'<br><br>'.join(error_messages)}")
return None
@@ -1023,7 +1031,11 @@ class FormulaFieldUpdater:
@frappe.whitelist()
def get_filtered_accounts(company: str, account_rows: str | list):
if not company:
frappe.throw(_("Company is required"), title=_("Missing Company"))
frappe.has_permission("Financial Report Template", ptype="read", throw=True)
frappe.has_permission("Company", doc=company, throw=True)
if isinstance(account_rows, str):
account_rows = json.loads(account_rows, object_hook=frappe._dict)

View File

@@ -403,10 +403,19 @@ class AccountFilterValidator(Validator):
self.account_fields = account_fields or set(self.account_meta._valid_columns)
def validate(self, row) -> ValidationResult:
result = ValidationResult()
# dispatch-path guard: only account-data rows are validated here
if row.data_source != "Account Data":
return result
return ValidationResult()
return self.validate_filter(row)
def validate_filter(self, row) -> ValidationResult:
"""Validate calculation_formula as an Account filter, regardless of data_source.
The caller has already decided this row is an account filter, so unlike
`validate()` this does not opt out based on `data_source`.
"""
result = ValidationResult()
try:
filter_config = json.loads(row.calculation_formula)
@@ -420,7 +429,7 @@ class AccountFilterValidator(Validator):
result.add_error(
ValidationIssue(
message=_("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label(row.data_source), error
get_formula_field_label("Account Data"), error
),
row_idx=row.idx,
)
@@ -430,7 +439,7 @@ class AccountFilterValidator(Validator):
result.add_error(
ValidationIssue(
message=_("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label(row.data_source),
get_formula_field_label("Account Data"),
_("Invalid JSON format: {0}").format(str(e)),
),
row_idx=row.idx,
@@ -455,10 +464,9 @@ class AccountFilterValidator(Validator):
if not isinstance(field, str) or not isinstance(operator, str):
return _("Field and operator must be strings")
display = (field if advanced_filtering else self.account_meta.get_label(field)) or field
if field not in account_fields:
return _("Field '{0}' is not a valid Account field").format(display)
# escape: `field` is caller-supplied and this message renders as HTML
return _("Field '{0}' is not a valid Account field").format(frappe.utils.escape_html(field))
if operator.casefold() not in OPERATOR_MAP:
return _("Invalid operator '{0}'").format(operator)

View File

@@ -5,6 +5,7 @@ import frappe
from frappe.tests.utils import whitelist_for_tests
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
AccountFilterValidator,
FormulaValidator,
get_valid_api_method,
)
@@ -164,3 +165,86 @@ class TestCustomAPIValidation(FinancialReportTemplateTestCase):
result = validator.validate(row)
self.assertFalse(result.is_valid)
self.assertEqual(len(frappe.local.message_log), message_count)
class TestAccountFilter(FinancialReportTemplateTestCase):
"""Filter fields must be validated on the account-filter parser path."""
@staticmethod
def _row(formula, **extra):
return frappe._dict(calculation_formula=formula, idx=1, **extra)
def test_validate_filter_enforces_allow_list_without_data_source(self):
# the parser path has no `data_source`; the field allow-list must still apply
validator = AccountFilterValidator()
self.assertFalse(validator.validate_filter(self._row('["bad_field", "=", "x"]')).is_valid)
self.assertTrue(validator.validate_filter(self._row('["root_type", "=", "Income"]')).is_valid)
def test_validate_gate_still_opts_out_for_non_account_data(self):
# validate() is the dispatch gate: it must not validate non "Account Data" rows
validator = AccountFilterValidator()
row = self._row('["bad_field", "=", "x"]', data_source="Custom API")
self.assertTrue(validator.validate(row).is_valid)
def test_error_message_labels_and_escapes_field(self):
validator = AccountFilterValidator()
result = validator.validate_filter(self._row('["<script>", "=", "x"]'))
message = str(result.issues[0])
self.assertIn("[Account Filter]", message)
self.assertIn("&lt;script&gt;", message)
self.assertNotIn("<script>", message)
def test_build_conditions_raises_on_invalid_field_when_opted_in(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FilterExpressionParser,
)
account = frappe.qb.DocType("Account")
rows = [self._row('["bad_field", "=", "x"]')]
parser = FilterExpressionParser()
# default: invalid rows are skipped, not raised
self.assertIsNone(parser.build_conditions(rows, account))
# opted in (the get_filtered_accounts path): invalid rows raise
self.assertRaises(
frappe.ValidationError, parser.build_conditions, rows, account, raise_on_invalid=True
)
def test_build_conditions_empty_returns_none(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FilterExpressionParser,
)
account = frappe.qb.DocType("Account")
self.assertIsNone(FilterExpressionParser().build_conditions([], account))
def test_endpoint_requires_company(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
get_filtered_accounts,
)
self.assertRaises(frappe.ValidationError, get_filtered_accounts, "", "[]")
def test_endpoint_rejects_invalid_field(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
get_filtered_accounts,
)
company = frappe.get_all("Company", limit=1, pluck="name")[0]
rows = frappe.as_json([{"calculation_formula": '["bad_field", "=", "x"]'}])
self.assertRaises(frappe.ValidationError, get_filtered_accounts, company, rows)
def test_endpoint_empty_rows_returns_all_company_accounts(self):
# filters are optional: no filter returns every enabled, non-group account of the company
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
get_filtered_accounts,
)
company = frappe.get_all("Company", limit=1, pluck="name")[0]
expected = frappe.get_all(
"Account",
filters={"company": company, "disabled": 0, "is_group": 0},
pluck="name",
)
self.assertEqual(sorted(get_filtered_accounts(company, "[]")), sorted(expected))