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"
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("