Skip to content

Commit a7d0f36

Browse files
committed
update report widget
1 parent 94990ac commit a7d0f36

2 files changed

Lines changed: 179 additions & 1 deletion

File tree

dojo/reports/widgets.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import json
33
from collections import OrderedDict
44

5+
import bleach
6+
from bleach.css_sanitizer import CSSSanitizer
57
from django import forms
68
from django.conf import settings
79
from django.forms import Widget
@@ -216,6 +218,51 @@ def get_option_form(self):
216218
return mark_safe(html)
217219

218220

221+
# Allow-list of the formatting the report builder's WYSIWYG editor can emit
222+
# (bold/italic/underline/strikethrough, lists, alignment, indentation, links,
223+
# images). Anything else -- <script>, on* handlers, javascript: URLs -- is
224+
# neutralized by bleach.clean() before the content reaches the |safe filter.
225+
WYSIWYG_ALLOWED_TAGS = {
226+
"a", "b", "strong", "i", "em", "u", "s", "strike", "sub", "sup",
227+
"p", "br", "hr", "div", "span", "blockquote", "pre", "code",
228+
"h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li",
229+
"table", "thead", "tbody", "tr", "th", "td", "img",
230+
}
231+
WYSIWYG_ALLOWED_STYLES = [
232+
"color", "background-color", "font-weight", "font-style",
233+
"text-decoration", "text-align", "margin-left", "padding-left",
234+
]
235+
# http(s)/mailto for link hrefs; data: is permitted (only on <img src>, see the
236+
# attribute filter below) so an inline base64 image renders without opening
237+
# data:text/html on a link.
238+
WYSIWYG_ALLOWED_PROTOCOLS = ["http", "https", "mailto", "data"]
239+
240+
241+
def wysiwyg_attribute_filter(tag, name, value):
242+
"""
243+
Bleach attribute allow-list for sanitized Custom Content.
244+
245+
A callable (rather than a dict) so data: URIs can be permitted on inline
246+
images -- <img src="data:image/..."> -- without also allowing them on link
247+
hrefs, where data:text/html would be an XSS/phishing vector. style/class are
248+
allowed everywhere; their values are constrained by the css_sanitizer.
249+
"""
250+
value = (value or "").strip().lower()
251+
if name in {"style", "class"}:
252+
return True
253+
if tag == "a":
254+
# Allow-list link schemes: data:/javascript:/obfuscated variants can't match an
255+
# allowed prefix, so they are dropped (bleach's protocol check is a second layer).
256+
return name in {"title", "target"} or (
257+
name == "href" and value.startswith(("http://", "https://", "mailto:"))
258+
)
259+
if tag == "img":
260+
return name in {"alt", "title", "width", "height"} or (
261+
name == "src" and value.startswith(("http://", "https://", "data:image/"))
262+
)
263+
return False
264+
265+
219266
class WYSIWYGContent(Widget):
220267
def __init__(self, *args, **kwargs):
221268
super().__init__(*args, **kwargs)
@@ -225,11 +272,23 @@ def __init__(self, *args, **kwargs):
225272
self.multiple = "true"
226273

227274
def get_html(self):
275+
# Sanitize the user-supplied WYSIWYG markup before the template renders
276+
# it with |safe. Keeps the editor's formatting but strips scripts,
277+
# event handlers and javascript: URLs (defense in depth: today this is
278+
# only self-reflected, but this closes the door if custom reports ever
279+
# become saveable/shareable).
280+
cleaned_content = bleach.clean(
281+
self.content or "",
282+
tags=WYSIWYG_ALLOWED_TAGS,
283+
attributes=wysiwyg_attribute_filter,
284+
protocols=WYSIWYG_ALLOWED_PROTOCOLS,
285+
css_sanitizer=CSSSanitizer(allowed_css_properties=WYSIWYG_ALLOWED_STYLES),
286+
)
228287
html = render_to_string(
229288
"dojo/custom_html_report_wysiwyg_content.html",
230289
{
231290
"heading": self.heading,
232-
"content": self.content,
291+
"content": cleaned_content,
233292
"page_break_after": self.page_break_after,
234293
})
235294
return mark_safe(html)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import json
2+
3+
from dojo.reports.widgets import report_widget_factory
4+
from unittests.dojo_test_case import DojoTestCase, versioned_fixtures
5+
6+
7+
@versioned_fixtures
8+
class TestCustomReportWysiwygXss(DojoTestCase):
9+
10+
"""
11+
Regression tests for the Custom Report Builder "Custom Content" (WYSIWYG)
12+
widget.
13+
14+
The widget renders user-supplied markup with Django's |safe filter, so the
15+
content must be sanitized before it reaches the template. These tests drive
16+
the same data flow as the report builder -- custom-content.hidden_content ->
17+
report_widget_factory() -> WYSIWYGContent.content -> get_html() -> the
18+
|safe template -- and assert that active content cannot be emitted as raw
19+
executable markup while legitimate rich-text formatting survives.
20+
"""
21+
22+
fixtures = ["dojo_testdata.json"]
23+
24+
# The exact payload validated in the original report.
25+
SCRIPT_PAYLOAD = "<script>alert(document.domain)</script><img src=x onerror=alert(1)>"
26+
JS_URL_PAYLOAD = '<a href="javascript:alert(1)">click me</a>'
27+
SAFE_PAYLOAD = '<b>bold</b> <u>underline</u> <a href="https://example.com/safe">safe link</a>'
28+
# <img> is allowed, so an inline base64 image must survive...
29+
DATA_IMAGE_PAYLOAD = (
30+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'
31+
'CAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC" alt="pixel">'
32+
)
33+
# ...but data: must be limited to images -- data:text/html on an <img src>...
34+
DATA_HTML_IMAGE_PAYLOAD = '<img src="data:text/html,<script>alert(1)</script>">'
35+
# ...and data: URLs must never be accepted on a link href.
36+
DATA_URL_LINK_PAYLOAD = '<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">link</a>'
37+
# Event handlers must be stripped even from allow-listed tags.
38+
EVENT_HANDLER_PAYLOAD = '<div onclick="alert(1)">click</div>'
39+
40+
def _render_custom_content(self, hidden_content, heading="Custom Content"):
41+
"""Render a Custom Content widget the way the report builder does."""
42+
widget_json = json.dumps([
43+
{"custom-content": [
44+
{"name": "heading", "value": heading},
45+
{"name": "hidden_content", "value": hidden_content},
46+
{"name": "page_break_after", "value": False},
47+
]},
48+
])
49+
widgets = report_widget_factory(json_data=widget_json, request=None)
50+
return str(widgets["custom-content-0"].get_html())
51+
52+
def test_script_is_neutralized_and_image_handler_is_stripped(self):
53+
"""<script> is escaped to inert text; <img> is kept but its onerror is removed."""
54+
html = self._render_custom_content(self.SCRIPT_PAYLOAD)
55+
56+
# <script> is escaped (rendered as inert text, not a raw tag)...
57+
self.assertNotIn("<script", html)
58+
# ...the image element itself is allowed...
59+
self.assertIn("<img", html)
60+
# ...but the event handler that made it dangerous is gone.
61+
self.assertNotIn("onerror", html)
62+
63+
def test_data_url_image_is_preserved(self):
64+
"""Inline data:image/ sources survive so pasted/base64 images still render."""
65+
html = self._render_custom_content(self.DATA_IMAGE_PAYLOAD)
66+
67+
self.assertIn("data:image/png;base64", html)
68+
69+
def test_data_url_non_image_is_stripped(self):
70+
"""data: on an <img src> is limited to images; data:text/html is dropped."""
71+
html = self._render_custom_content(self.DATA_HTML_IMAGE_PAYLOAD)
72+
73+
self.assertNotIn("data:text/html", html)
74+
self.assertNotIn("<script", html)
75+
76+
def test_data_url_link_is_stripped(self):
77+
"""data: URLs are never allowed on link hrefs (only on <img src>)."""
78+
html = self._render_custom_content(self.DATA_URL_LINK_PAYLOAD)
79+
80+
self.assertNotIn("data:text/html", html)
81+
self.assertIn("link", html) # anchor text is preserved
82+
83+
def test_obfuscated_data_url_link_is_stripped(self):
84+
"""data: on a link href stays blocked even when its scheme is split by control chars."""
85+
for payload in (
86+
'<a href="da&#10;ta:text/html,alert(1)">l</a>', # newline in scheme
87+
'<a href="da&#9;ta:text/html,alert(1)">l</a>', # tab in scheme
88+
):
89+
html = self._render_custom_content(payload)
90+
self.assertNotIn("ta:text/html", html)
91+
92+
def test_event_handler_on_allowed_tag_is_stripped(self):
93+
"""Event-handler attributes are removed even from allow-listed tags."""
94+
html = self._render_custom_content(self.EVENT_HANDLER_PAYLOAD)
95+
96+
self.assertNotIn("onclick", html)
97+
self.assertIn("click", html)
98+
99+
def test_javascript_url_is_stripped(self):
100+
"""javascript: hrefs are removed; the anchor text is preserved."""
101+
html = self._render_custom_content(self.JS_URL_PAYLOAD)
102+
103+
self.assertNotIn("javascript:", html)
104+
self.assertIn("click me", html)
105+
106+
def test_safe_formatting_is_preserved(self):
107+
"""The formatting the WYSIWYG editor emits must pass through intact."""
108+
html = self._render_custom_content(self.SAFE_PAYLOAD)
109+
110+
self.assertIn("<b>bold</b>", html)
111+
self.assertIn("<u>underline</u>", html)
112+
self.assertIn('href="https://example.com/safe"', html)
113+
self.assertIn("safe link", html)
114+
115+
def test_heading_is_escaped(self):
116+
"""The heading is auto-escaped and cannot be used to inject markup."""
117+
html = self._render_custom_content("benign", heading="<script>alert(1)</script>")
118+
119+
self.assertNotIn("<script", html)

0 commit comments

Comments
 (0)