Skip to content

Commit aaa9001

Browse files
Merge pull request #140 from OpenSPP/fix/html-escape-sanitize-false-fields
fix: add HTML escaping to sanitize=False computed Html fields
2 parents f738582 + 7ee9652 commit aaa9001

File tree

15 files changed

+399
-34
lines changed

15 files changed

+399
-34
lines changed

spp_audit/README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ Changelog
154154
all records, not just the first
155155
- refactor: use ``isinstance(value, Markup)`` instead of fragile
156156
``str(type(...))`` comparison in all audit decorator methods
157+
- fix: add HTML escaping to computed ``data_html`` and
158+
``parent_data_html`` fields to prevent stored XSS (#50)
157159

158160
19.0.2.0.0
159161
~~~~~~~~~~

spp_audit/models/spp_audit_log.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33

44
from dateutil import tz
5+
from markupsafe import escape as html_escape
56

67
from odoo import _, api, fields, models
78
from odoo.exceptions import UserError
@@ -164,7 +165,7 @@ def _compute_data_html(self):
164165
for line in rec._get_content():
165166
row = ""
166167
for item in line:
167-
row += f"<td>{item}</td>"
168+
row += f"<td>{html_escape(str(item))}</td>"
168169
tbody += f"<tr>{row}</tr>"
169170
tbody = f"<tbody>{tbody}</tbody>"
170171
rec.data_html = f'<table class="o_list_view table table-condensed table-striped">{thead}{tbody}</table>'
@@ -206,7 +207,7 @@ def _compute_parent_data_html(self):
206207
for line in rec._parent_get_content():
207208
row = ""
208209
for item in line:
209-
row += f"<td>{item}</td>"
210+
row += f"<td>{html_escape(str(item))}</td>"
210211
tbody += f"<tr>{row}</tr>"
211212
tbody = f"<tbody>{tbody}</tbody>"
212213
rec.parent_data_html = (

spp_audit/readme/HISTORY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- fix: use @api.model_create_multi for audit_create to support Odoo 19 create overrides (#138)
44
- fix: Markup sanitization in audit_write and audit_unlink now handles all records, not just the first
55
- refactor: use `isinstance(value, Markup)` instead of fragile `str(type(...))` comparison in all audit decorator methods
6+
- fix: add HTML escaping to computed `data_html` and `parent_data_html` fields to prevent stored XSS (#50)
67

78
### 19.0.2.0.0
89

spp_audit/static/description/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,8 @@ <h1>19.0.2.0.1</h1>
524524
all records, not just the first</li>
525525
<li>refactor: use <tt class="docutils literal">isinstance(value, Markup)</tt> instead of fragile
526526
<tt class="docutils literal"><span class="pre">str(type(...))</span></tt> comparison in all audit decorator methods</li>
527+
<li>fix: add HTML escaping to computed <tt class="docutils literal">data_html</tt> and
528+
<tt class="docutils literal">parent_data_html</tt> fields to prevent stored XSS (#50)</li>
527529
</ul>
528530
</div>
529531
<div class="section" id="section-2">

spp_audit/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from . import test_audit_backend
2+
from . import test_html_escaping
23
from . import test_spp_audit_rule
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from odoo.tests.common import TransactionCase
2+
3+
4+
class TestAuditHtmlEscaping(TransactionCase):
5+
"""Tests that audit log HTML fields properly escape dynamic values."""
6+
7+
@classmethod
8+
def setUpClass(cls):
9+
super().setUpClass()
10+
cls.model_partner = cls.env["ir.model"].search([("model", "=", "res.partner")], limit=1)
11+
cls.audit_rule = cls.env["spp.audit.rule"].search([("model_id", "=", cls.model_partner.id)], limit=1)
12+
if not cls.audit_rule:
13+
cls.audit_rule = cls.env["spp.audit.rule"].create(
14+
{
15+
"name": "Test Rule",
16+
"model_id": cls.model_partner.id,
17+
"is_log_create": True,
18+
"is_log_write": True,
19+
"is_log_unlink": False,
20+
}
21+
)
22+
23+
def _create_audit_log(self, old_vals, new_vals):
24+
"""Create an audit log record with given old/new values."""
25+
data = repr({"old": old_vals, "new": new_vals})
26+
return self.env["spp.audit.log"].create(
27+
{
28+
"audit_rule_id": self.audit_rule.id,
29+
"user_id": self.env.uid,
30+
"model_id": self.model_partner.id,
31+
"res_id": 1,
32+
"method": "write",
33+
"data": data,
34+
}
35+
)
36+
37+
def test_data_html_escapes_script_tags(self):
38+
"""Verify data_html escapes <script> in field values."""
39+
xss_payload = '<script>alert("xss")</script>'
40+
log = self._create_audit_log(
41+
old_vals={"name": "Safe Name"},
42+
new_vals={"name": xss_payload},
43+
)
44+
html = log.data_html
45+
self.assertNotIn("<script>", html)
46+
self.assertIn("&lt;script&gt;", html)
47+
48+
def test_data_html_escapes_html_entities(self):
49+
"""Verify data_html escapes angle brackets and ampersands."""
50+
log = self._create_audit_log(
51+
old_vals={"name": "Before <b>bold</b>"},
52+
new_vals={"name": "After <img src=x onerror=alert(1)>"},
53+
)
54+
html = log.data_html
55+
self.assertNotIn("<img ", html)
56+
self.assertIn("&lt;img ", html)
57+
self.assertNotIn("<b>bold</b>", html)
58+
self.assertIn("&lt;b&gt;bold&lt;/b&gt;", html)
59+
60+
def test_data_html_renders_table_structure(self):
61+
"""Verify data_html still produces valid table structure."""
62+
log = self._create_audit_log(
63+
old_vals={"name": "Old"},
64+
new_vals={"name": "New"},
65+
)
66+
html = log.data_html
67+
self.assertIn("<table", html)
68+
self.assertIn("<thead>", html)
69+
self.assertIn("<tbody>", html)
70+
self.assertIn("<td>", html)
71+
72+
def test_parent_data_html_escapes_script_tags(self):
73+
"""Verify parent_data_html escapes <script> in field values."""
74+
xss_payload = '<script>alert("xss")</script>'
75+
parent_model = self.env["ir.model"].search([("model", "=", "res.partner")], limit=1)
76+
log = self.env["spp.audit.log"].create(
77+
{
78+
"audit_rule_id": self.audit_rule.id,
79+
"user_id": self.env.uid,
80+
"model_id": self.model_partner.id,
81+
"res_id": 1,
82+
"method": "write",
83+
"data": repr({"old": {"name": "Safe"}, "new": {"name": xss_payload}}),
84+
"parent_model_id": parent_model.id,
85+
"parent_res_ids_str": "1",
86+
}
87+
)
88+
html = log.parent_data_html
89+
self.assertNotIn("<script>", html)
90+
self.assertIn("&lt;script&gt;", html)

spp_change_request_v2/README.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,12 @@ Before declaring a new CR type complete:
853853
Changelog
854854
=========
855855

856+
19.0.2.0.3
857+
~~~~~~~~~~
858+
859+
- fix: add HTML escaping to all computed Html fields with
860+
``sanitize=False`` to prevent stored XSS (#50)
861+
856862
19.0.2.0.2
857863
~~~~~~~~~~
858864

spp_change_request_v2/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "OpenSPP Change Request V2",
3-
"version": "19.0.2.0.2",
3+
"version": "19.0.2.0.3",
44
"sequence": 50,
55
"category": "OpenSPP",
66
"summary": "Configuration-driven change request system with UX improvements, conflict detection and duplicate prevention",

spp_change_request_v2/models/change_request.py

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -380,11 +380,11 @@ def _compute_target_type_message(self):
380380
@api.depends("name", "request_type_id", "registrant_id")
381381
def _compute_stage_banner_html(self):
382382
for rec in self:
383-
cr_ref = rec.name or ""
384-
cr_type = rec.request_type_id.name if rec.request_type_id else ""
383+
cr_ref = html_escape(rec.name or "")
384+
cr_type = html_escape(rec.request_type_id.name) if rec.request_type_id else ""
385385
html = f'<span class="fw-bold">{cr_ref}</span><span class="text-muted mx-2">|</span><span>{cr_type}</span>'
386386
if rec.registrant_id:
387-
registrant = rec.registrant_id.name or ""
387+
registrant = html_escape(rec.registrant_id.name or "")
388388
html += (
389389
f'<span class="text-muted mx-2">|</span>'
390390
f'<i class="fa fa-user me-1 text-muted"></i>'
@@ -422,14 +422,11 @@ def _compute_required_documents_html(self):
422422
uploaded_types = rec.document_ids.mapped("document_type_id").filtered(lambda c: c)
423423
items = []
424424
for doc_type in required:
425+
escaped_name = html_escape(doc_type.display_name)
425426
if doc_type in uploaded_types:
426-
items.append(
427-
f'<li class="text-success"><i class="fa fa-check-circle me-1"></i>{doc_type.display_name}</li>'
428-
)
427+
items.append(f'<li class="text-success"><i class="fa fa-check-circle me-1"></i>{escaped_name}</li>')
429428
else:
430-
items.append(
431-
f'<li class="text-danger"><i class="fa fa-times-circle me-1"></i>{doc_type.display_name}</li>'
432-
)
429+
items.append(f'<li class="text-danger"><i class="fa fa-times-circle me-1"></i>{escaped_name}</li>')
433430

434431
rec.required_documents_html = (
435432
'<div class="mb-3">'
@@ -508,8 +505,8 @@ def _compute_review_documents_html(self):
508505
html.append("<tbody>")
509506

510507
for doc in rec.document_ids:
511-
doc_name = doc.name or ""
512-
doc_type = doc.document_type_id.display_name if doc.document_type_id else ""
508+
doc_name = html_escape(doc.name or "")
509+
doc_type = html_escape(doc.document_type_id.display_name) if doc.document_type_id else ""
513510
uploaded = doc.create_date.strftime("%Y-%m-%d") if doc.create_date else ""
514511
html.append(
515512
f"<tr>"
@@ -541,19 +538,20 @@ def _compute_registrant_summary_html(self):
541538
html_parts.append('<i class="fa fa-users fa-lg text-primary me-2"></i>')
542539
else:
543540
html_parts.append('<i class="fa fa-user fa-lg text-primary me-2"></i>')
544-
html_parts.append(f"<strong>{reg.name}</strong>")
541+
html_parts.append(f"<strong>{html_escape(reg.name or '')}</strong>")
545542
html_parts.append("</div>")
546543

547544
# ID badge
548545
if hasattr(reg, "spp_id") and reg.spp_id:
549-
html_parts.append(f'<div class="mb-2"><span class="badge bg-secondary">ID: {reg.spp_id}</span></div>')
546+
escaped_id = html_escape(reg.spp_id)
547+
html_parts.append(f'<div class="mb-2"><span class="badge bg-secondary">ID: {escaped_id}</span></div>')
550548

551549
# Address
552550
address_parts = []
553551
if reg.street:
554-
address_parts.append(reg.street)
552+
address_parts.append(html_escape(reg.street))
555553
if reg.city:
556-
address_parts.append(reg.city)
554+
address_parts.append(html_escape(reg.city))
557555
if address_parts:
558556
html_parts.append(
559557
f'<div class="text-muted small mb-2">'
@@ -1073,7 +1071,7 @@ def _generate_preview_html(self):
10731071
action_label = action_labels.get(action, action.replace("_", " ").title())
10741072
html_parts.append(
10751073
f'<div class="mb-3 d-flex align-items-center">'
1076-
f'<span class="badge bg-primary me-2">{action_label}</span>'
1074+
f'<span class="badge bg-primary me-2">{html_escape(action_label)}</span>'
10771075
f"</div>"
10781076
)
10791077

@@ -1085,7 +1083,7 @@ def _generate_preview_html(self):
10851083
for key, value in changes.items():
10861084
if key.startswith("_"):
10871085
continue
1088-
display_key = key.replace("_", " ").title()
1086+
display_key = html_escape(key.replace("_", " ").title())
10891087

10901088
# Handle dict with old/new structure
10911089
if isinstance(value, dict) and "new" in value:
@@ -1095,16 +1093,16 @@ def _generate_preview_html(self):
10951093
if old_val is None or old_val is False or old_val == "":
10961094
old_display = '<span class="text-muted">—</span>'
10971095
else:
1098-
old_display = str(old_val)
1096+
old_display = html_escape(str(old_val))
10991097
# Format new value
11001098
if new_val is None or new_val is False or new_val == "":
11011099
new_display = '<span class="text-muted">—</span>'
11021100
else:
1103-
new_display = f"<strong>{new_val}</strong>"
1101+
new_display = f"<strong>{html_escape(str(new_val))}</strong>"
11041102
display_value = f"{old_display}{new_display}"
11051103
elif isinstance(value, list):
11061104
if value:
1107-
display_value = "<br/>".join(str(v) for v in value)
1105+
display_value = "<br/>".join(html_escape(str(v)) for v in value)
11081106
else:
11091107
display_value = '<span class="text-muted">Not set</span>'
11101108
elif value is None or value is False or value == "":
@@ -1114,7 +1112,7 @@ def _generate_preview_html(self):
11141112
# Only True reaches here (False caught above)
11151113
display_value = '<span class="badge text-bg-success">Yes</span>'
11161114
else:
1117-
display_value = str(value)
1115+
display_value = html_escape(str(value))
11181116

11191117
html_parts.append(f"<tr><td><strong>{display_key}</strong></td><td>{display_value}</td></tr>")
11201118

@@ -1167,7 +1165,7 @@ def _render_comparison_table(self, changes, header=None):
11671165
"""Render a three-column comparison table for field-mapping CR types."""
11681166
html = []
11691167
if header:
1170-
html.append(f"<h4>{header}</h4>")
1168+
html.append(f"<h4>{html_escape(header)}</h4>")
11711169
html.append('<table class="table table-sm table-bordered mb-0" style="width:100%">')
11721170
html.append(
11731171
"<thead><tr>"
@@ -1182,7 +1180,7 @@ def _render_comparison_table(self, changes, header=None):
11821180
if key.startswith("_"):
11831181
continue
11841182
# Use key as-is if it contains spaces (human-readable), otherwise convert
1185-
display_key = key if " " in key else key.replace("_", " ").title()
1183+
display_key = html_escape(key if " " in key else key.replace("_", " ").title())
11861184

11871185
if isinstance(value, dict) and "old" in value:
11881186
old_val = value.get("old")
@@ -1220,7 +1218,7 @@ def _render_action_summary(self, action, changes, header=None):
12201218
html = []
12211219

12221220
if header:
1223-
html.append(f"<h4>{header}</h4>")
1221+
html.append(f"<h4>{html_escape(header)}</h4>")
12241222

12251223
if not changes:
12261224
html.append('<p class="text-muted mb-0"><i class="fa fa-info-circle me-2"></i>No details to display.</p>')
@@ -1233,7 +1231,7 @@ def _render_action_summary(self, action, changes, header=None):
12331231
for key, value in changes.items():
12341232
if key.startswith("_"):
12351233
continue
1236-
display_key = key if " " in key else key.replace("_", " ").title()
1234+
display_key = html_escape(key if " " in key else key.replace("_", " ").title())
12371235
display_value = self._format_review_value(value)
12381236
html.append(f'<tr><td class="bg-light"><strong>{display_key}</strong></td><td>{display_value}</td></tr>')
12391237

spp_change_request_v2/readme/HISTORY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
### 19.0.2.0.3
2+
3+
- fix: add HTML escaping to all computed Html fields with `sanitize=False` to prevent stored XSS (#50)
4+
15
### 19.0.2.0.2
26

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

0 commit comments

Comments
 (0)