diff --git a/spp_audit/README.rst b/spp_audit/README.rst index ab20a7e6..89f2136c 100644 --- a/spp_audit/README.rst +++ b/spp_audit/README.rst @@ -154,6 +154,8 @@ Changelog all records, not just the first - refactor: use ``isinstance(value, Markup)`` instead of fragile ``str(type(...))`` comparison in all audit decorator methods +- fix: add HTML escaping to computed ``data_html`` and + ``parent_data_html`` fields to prevent stored XSS (#50) 19.0.2.0.0 ~~~~~~~~~~ diff --git a/spp_audit/models/spp_audit_log.py b/spp_audit/models/spp_audit_log.py index 753b4be9..ba78e983 100644 --- a/spp_audit/models/spp_audit_log.py +++ b/spp_audit/models/spp_audit_log.py @@ -2,6 +2,7 @@ import logging from dateutil import tz +from markupsafe import escape as html_escape from odoo import _, api, fields, models from odoo.exceptions import UserError @@ -164,7 +165,7 @@ def _compute_data_html(self): for line in rec._get_content(): row = "" for item in line: - row += f"{item}" + row += f"{html_escape(str(item))}" tbody += f"{row}" tbody = f"{tbody}" rec.data_html = f'{thead}{tbody}
' @@ -206,7 +207,7 @@ def _compute_parent_data_html(self): for line in rec._parent_get_content(): row = "" for item in line: - row += f"{item}" + row += f"{html_escape(str(item))}" tbody += f"{row}" tbody = f"{tbody}" rec.parent_data_html = ( diff --git a/spp_audit/readme/HISTORY.md b/spp_audit/readme/HISTORY.md index 35ab4f8e..ded6e2a2 100644 --- a/spp_audit/readme/HISTORY.md +++ b/spp_audit/readme/HISTORY.md @@ -3,6 +3,7 @@ - fix: use @api.model_create_multi for audit_create to support Odoo 19 create overrides (#138) - fix: Markup sanitization in audit_write and audit_unlink now handles all records, not just the first - refactor: use `isinstance(value, Markup)` instead of fragile `str(type(...))` comparison in all audit decorator methods +- fix: add HTML escaping to computed `data_html` and `parent_data_html` fields to prevent stored XSS (#50) ### 19.0.2.0.0 diff --git a/spp_audit/static/description/index.html b/spp_audit/static/description/index.html index 876fd881..6a179c25 100644 --- a/spp_audit/static/description/index.html +++ b/spp_audit/static/description/index.html @@ -524,6 +524,8 @@

19.0.2.0.1

all records, not just the first
  • refactor: use isinstance(value, Markup) instead of fragile str(type(...)) comparison in all audit decorator methods
  • +
  • fix: add HTML escaping to computed data_html and +parent_data_html fields to prevent stored XSS (#50)
  • diff --git a/spp_audit/tests/__init__.py b/spp_audit/tests/__init__.py index a0dcf87c..80bad074 100644 --- a/spp_audit/tests/__init__.py +++ b/spp_audit/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_audit_backend +from . import test_html_escaping from . import test_spp_audit_rule diff --git a/spp_audit/tests/test_html_escaping.py b/spp_audit/tests/test_html_escaping.py new file mode 100644 index 00000000..a9f336a9 --- /dev/null +++ b/spp_audit/tests/test_html_escaping.py @@ -0,0 +1,90 @@ +from odoo.tests.common import TransactionCase + + +class TestAuditHtmlEscaping(TransactionCase): + """Tests that audit log HTML fields properly escape dynamic values.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.model_partner = cls.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + cls.audit_rule = cls.env["spp.audit.rule"].search([("model_id", "=", cls.model_partner.id)], limit=1) + if not cls.audit_rule: + cls.audit_rule = cls.env["spp.audit.rule"].create( + { + "name": "Test Rule", + "model_id": cls.model_partner.id, + "is_log_create": True, + "is_log_write": True, + "is_log_unlink": False, + } + ) + + def _create_audit_log(self, old_vals, new_vals): + """Create an audit log record with given old/new values.""" + data = repr({"old": old_vals, "new": new_vals}) + return self.env["spp.audit.log"].create( + { + "audit_rule_id": self.audit_rule.id, + "user_id": self.env.uid, + "model_id": self.model_partner.id, + "res_id": 1, + "method": "write", + "data": data, + } + ) + + def test_data_html_escapes_script_tags(self): + """Verify data_html escapes ' + log = self._create_audit_log( + old_vals={"name": "Safe Name"}, + new_vals={"name": xss_payload}, + ) + html = log.data_html + self.assertNotIn("' + parent_model = self.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + log = self.env["spp.audit.log"].create( + { + "audit_rule_id": self.audit_rule.id, + "user_id": self.env.uid, + "model_id": self.model_partner.id, + "res_id": 1, + "method": "write", + "data": repr({"old": {"name": "Safe"}, "new": {"name": xss_payload}}), + "parent_model_id": parent_model.id, + "parent_res_ids_str": "1", + } + ) + html = log.parent_data_html + self.assertNotIn("' + registrant = self.partner_model.create( + { + "name": xss_name, + "is_registrant": True, + "is_group": False, + } + ) + cr = self._create_cr(registrant=registrant) + cr.invalidate_recordset() + html = cr.registrant_summary_html + self.assertNotIn("' + cr = self._create_cr(registrant=registrant) + cr.invalidate_recordset() + html = cr.registrant_summary_html + self.assertNotIn("', + "new": '', + }, + "notes": '', + } + # Mock the strategy.preview() to return XSS payloads + with patch.object(type(cr.request_type_id), "get_apply_strategy") as mock_strategy: + mock_strategy.return_value.preview.return_value = xss_changes + cr.invalidate_recordset() + html = cr._generate_preview_html() + + self.assertNotIn("", "safe value"], + } + with patch.object(type(cr.request_type_id), "get_apply_strategy") as mock_strategy: + mock_strategy.return_value.preview.return_value = xss_changes + cr.invalidate_recordset() + html = cr._generate_preview_html() + + self.assertNotIn("', + "field": "value", + } + with patch.object(type(cr.request_type_id), "get_apply_strategy") as mock_strategy: + mock_strategy.return_value.preview.return_value = xss_changes + wizard.invalidate_recordset() + html = wizard.preview_html + + self.assertNotIn("", "safe"], + } + with patch.object(type(cr.request_type_id), "get_apply_strategy") as mock_strategy: + mock_strategy.return_value.preview.return_value = xss_changes + wizard.invalidate_recordset() + html = wizard.preview_html + + self.assertNotIn("', + } + ) + cr.write({"is_applied": True, "preview_json_snapshot": xss_snapshot}) + wizard = self._create_wizard(cr) + wizard.invalidate_recordset() + html = wizard.preview_html + + self.assertNotIn("