From 18d1a78adaacd666dabfcce0106998c42a02d147 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 26 Mar 2026 16:16:43 +0800 Subject: [PATCH 1/6] fix(spp_audit,spp_change_request_v2): add HTML escaping to computed Html fields Escape dynamic values with markupsafe.escape() before inserting into f-string HTML to prevent stored XSS in audit logs, change request previews, and registrant summaries. Fixes #50 --- spp_audit/models/spp_audit_log.py | 5 +++-- .../models/change_request.py | 21 ++++++++++--------- .../wizards/preview_changes_wizard.py | 10 +++++---- 3 files changed, 20 insertions(+), 16 deletions(-) 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_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py index 9dc0d2f2..38f4b9dc 100644 --- a/spp_change_request_v2/models/change_request.py +++ b/spp_change_request_v2/models/change_request.py @@ -541,19 +541,20 @@ def _compute_registrant_summary_html(self): html_parts.append('') else: html_parts.append('') - html_parts.append(f"{reg.name}") + html_parts.append(f"{html_escape(reg.name or '')}") html_parts.append("") # ID badge if hasattr(reg, "spp_id") and reg.spp_id: - html_parts.append(f'
ID: {reg.spp_id}
') + escaped_id = html_escape(reg.spp_id) + html_parts.append(f'
ID: {escaped_id}
') # Address address_parts = [] if reg.street: - address_parts.append(reg.street) + address_parts.append(html_escape(reg.street)) if reg.city: - address_parts.append(reg.city) + address_parts.append(html_escape(reg.city)) if address_parts: html_parts.append( f'
' @@ -1073,7 +1074,7 @@ def _generate_preview_html(self): action_label = action_labels.get(action, action.replace("_", " ").title()) html_parts.append( f'
' - f'{action_label}' + f'{html_escape(action_label)}' f"
" ) @@ -1085,7 +1086,7 @@ def _generate_preview_html(self): for key, value in changes.items(): if key.startswith("_"): continue - display_key = key.replace("_", " ").title() + display_key = html_escape(key.replace("_", " ").title()) # Handle dict with old/new structure if isinstance(value, dict) and "new" in value: @@ -1095,16 +1096,16 @@ def _generate_preview_html(self): if old_val is None or old_val is False or old_val == "": old_display = '' else: - old_display = str(old_val) + old_display = html_escape(str(old_val)) # Format new value if new_val is None or new_val is False or new_val == "": new_display = '' else: - new_display = f"{new_val}" + new_display = f"{html_escape(str(new_val))}" display_value = f"{old_display} → {new_display}" elif isinstance(value, list): if value: - display_value = "
".join(str(v) for v in value) + display_value = "
".join(html_escape(str(v)) for v in value) else: display_value = 'Not set' elif value is None or value is False or value == "": @@ -1114,7 +1115,7 @@ def _generate_preview_html(self): # Only True reaches here (False caught above) display_value = 'Yes' else: - display_value = str(value) + display_value = html_escape(str(value)) html_parts.append(f"{display_key}{display_value}") diff --git a/spp_change_request_v2/wizards/preview_changes_wizard.py b/spp_change_request_v2/wizards/preview_changes_wizard.py index 7b8336b1..4e73dc8c 100644 --- a/spp_change_request_v2/wizards/preview_changes_wizard.py +++ b/spp_change_request_v2/wizards/preview_changes_wizard.py @@ -3,6 +3,8 @@ import json +from markupsafe import escape as html_escape + from odoo import api, fields, models @@ -61,7 +63,7 @@ def _compute_preview_html(self): html_parts = ['
'] action = changes.pop("_action", "update") - html_parts.append(f'

Action: {action}

') + html_parts.append(f'

Action: {html_escape(str(action))}

') if changes: html_parts.append('') @@ -70,10 +72,10 @@ def _compute_preview_html(self): for key, value in changes.items(): # Format the key - display_key = key.replace("_", " ").title() + display_key = html_escape(key.replace("_", " ").title()) # Format the value if isinstance(value, list): - display_value = "
".join(str(v) for v in value) + display_value = "
".join(html_escape(str(v)) for v in value) elif value is None: display_value = 'Not set' elif isinstance(value, bool): @@ -83,7 +85,7 @@ def _compute_preview_html(self): else 'No' ) else: - display_value = str(value) + display_value = html_escape(str(value)) html_parts.append(f"") From 1599361dd301a763698699bedc607c7dcb3f2f23 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 26 Mar 2026 16:46:14 +0800 Subject: [PATCH 2/6] test(spp_audit,spp_change_request_v2): add HTML escaping coverage tests Verify that computed Html fields properly escape XSS payloads in dynamic values (script tags, img onerror, etc.) for audit logs, change request previews, registrant summaries, and preview wizard. --- spp_audit/tests/__init__.py | 1 + spp_audit/tests/test_html_escaping.py | 89 +++++++++++ spp_change_request_v2/tests/__init__.py | 2 + .../tests/test_html_escaping.py | 141 ++++++++++++++++++ .../tests/test_wizard_html_escaping.py | 107 +++++++++++++ 5 files changed, 340 insertions(+) create mode 100644 spp_audit/tests/test_html_escaping.py create mode 100644 spp_change_request_v2/tests/test_html_escaping.py create mode 100644 spp_change_request_v2/tests/test_wizard_html_escaping.py 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..f189c3d7 --- /dev/null +++ b/spp_audit/tests/test_html_escaping.py @@ -0,0 +1,89 @@ +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("', "safe value"], + "tags": ["", "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 diff --git a/spp_change_request_v2/tests/test_wizard_html_escaping.py b/spp_change_request_v2/tests/test_wizard_html_escaping.py index 200f8529..1849dbc1 100644 --- a/spp_change_request_v2/tests/test_wizard_html_escaping.py +++ b/spp_change_request_v2/tests/test_wizard_html_escaping.py @@ -21,9 +21,7 @@ def _create_cr(self, registrant=None, cr_type=None): def _create_wizard(self, cr): """Create a preview wizard for the given CR.""" - return self.env["spp.cr.preview.wizard"].create( - {"change_request_id": cr.id} - ) + return self.env["spp.cr.preview.wizard"].create({"change_request_id": cr.id}) def test_wizard_preview_escapes_action(self): """Verify wizard preview_html escapes XSS in action value.""" @@ -47,7 +45,7 @@ def test_wizard_preview_escapes_field_values(self): wizard = self._create_wizard(cr) xss_changes = { "_action": "update", - "safe_field": '', + "safe_field": "", } with patch.object(type(cr.request_type_id), "get_apply_strategy") as mock_strategy: mock_strategy.return_value.preview.return_value = xss_changes @@ -63,7 +61,7 @@ def test_wizard_preview_escapes_list_values(self): wizard = self._create_wizard(cr) xss_changes = { "_action": "update", - "tags": ['', "safe"], + "tags": ["", "safe"], } with patch.object(type(cr.request_type_id), "get_apply_strategy") as mock_strategy: mock_strategy.return_value.preview.return_value = xss_changes @@ -77,10 +75,12 @@ def test_wizard_preview_escapes_list_values(self): def test_wizard_preview_from_snapshot_escapes(self): """Verify wizard escapes values from stored JSON snapshots.""" cr = self._create_cr() - xss_snapshot = json.dumps({ - "_action": "update", - "name": '', - }) + xss_snapshot = json.dumps( + { + "_action": "update", + "name": '', + } + ) cr.write({"is_applied": True, "preview_json_snapshot": xss_snapshot}) wizard = self._create_wizard(cr) wizard.invalidate_recordset() From 6631cffdccdd3f6d8e84cd3af1b520634688375e Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 26 Mar 2026 17:28:52 +0800 Subject: [PATCH 4/6] chore(spp_audit,spp_change_request_v2): bump versions and add changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spp_audit 19.0.2.0.0 → 19.0.2.0.1 spp_change_request_v2 19.0.2.0.2 → 19.0.2.0.3 --- spp_audit/README.rst | 2 ++ spp_audit/readme/HISTORY.md | 1 + spp_change_request_v2/README.rst | 6 ++++++ spp_change_request_v2/__manifest__.py | 2 +- spp_change_request_v2/readme/HISTORY.md | 4 ++++ 5 files changed, 14 insertions(+), 1 deletion(-) 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/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_change_request_v2/README.rst b/spp_change_request_v2/README.rst index afbdfd41..346551e7 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -853,6 +853,12 @@ Before declaring a new CR type complete: Changelog ========= +19.0.2.0.3 +~~~~~~~~~~ + +- fix: add HTML escaping to all computed Html fields with + ``sanitize=False`` to prevent stored XSS (#50) + 19.0.2.0.2 ~~~~~~~~~~ diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index a7a41763..c565429a 100644 --- a/spp_change_request_v2/__manifest__.py +++ b/spp_change_request_v2/__manifest__.py @@ -1,6 +1,6 @@ { "name": "OpenSPP Change Request V2", - "version": "19.0.2.0.2", + "version": "19.0.2.0.3", "sequence": 50, "category": "OpenSPP", "summary": "Configuration-driven change request system with UX improvements, conflict detection and duplicate prevention", diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index dca3563a..387d84da 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.0.3 + +- fix: add HTML escaping to all computed Html fields with `sanitize=False` to prevent stored XSS (#50) + ### 19.0.2.0.2 - fix: fix batch approval wizard line deletion (#130) From aeb197fa52fba955a3d6f87fbb51027560952299 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 26 Mar 2026 17:54:40 +0800 Subject: [PATCH 5/6] chore: regenerate static/description/index.html files --- spp_audit/static/description/index.html | 2 ++ spp_change_request_v2/static/description/index.html | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) 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_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index d2e9936e..f8bf3e1a 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,19 +1339,26 @@

    Changelog

    +

    19.0.2.0.3

    +
      +
    • fix: add HTML escaping to all computed Html fields with +sanitize=False to prevent stored XSS (#50)
    • +
    +
    +

    19.0.2.0.2

    • fix: fix batch approval wizard line deletion (#130)
    -
    +

    19.0.2.0.1

    • fix: skip field types before getattr and isolate detail prefetch (#129)
    -
    +

    19.0.2.0.0

    • Initial migration to OpenSPP2
    • From 7ee9652a258fcaab9e15616f2b4857eef780a97f Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 6 Apr 2026 10:04:01 +0800 Subject: [PATCH 6/6] test(spp_audit,spp_change_request_v2): assert escaped entities in XSS tests Add assertIn("<script>") to two tests that only checked absence of raw
    {display_key}{display_value}