Skip to content

Commit 0990ed3

Browse files
committed
Escape and sanitize all variables and data passed to js
1 parent d9859b2 commit 0990ed3

File tree

12 files changed

+420
-44
lines changed

12 files changed

+420
-44
lines changed

docs/api/autocomplete_views.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,3 +365,14 @@ AutocompleteModelView.invalidate_permissions(user=request.user)
365365
# Invalidate all cached permissions
366366
AutocompleteModelView.invalidate_permissions()
367367
```
368+
369+
## Security Considerations
370+
371+
When creating custom templates and renderers for Tom Select widgets, always ensure proper escaping of user-provided values:
372+
373+
1. Use the `escape()` function for any user data inserted into HTML content
374+
2. For URL attributes (href, src), always escape the URLs using the `escape()` function
375+
3. Avoid using `new Function()` with user-provided content whenever possible
376+
4. When customizing rendering templates, validate and sanitize all input
377+
378+
This is particularly important when using custom rendering templates with `data_template_option` and `data_template_item`.

example_project/test_utils.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Tests for django-tomselect's utils."""
2+
3+
import pytest
4+
5+
from django_tomselect.utils import safe_escape, safe_url, sanitize_dict
6+
7+
8+
class TestUtilityFunctions:
9+
"""Test utility functions for proper escaping."""
10+
11+
def test_safe_escape(self):
12+
"""Test that safe_escape properly handles HTML content."""
13+
# Test with HTML content
14+
html = '<script>alert("XSS");</script>'
15+
assert safe_escape(html) == "&lt;script&gt;alert(&quot;XSS&quot;);&lt;/script&gt;"
16+
17+
# Test with None value
18+
assert safe_escape(None) == ""
19+
20+
# Test with non-string value
21+
assert safe_escape(123) == "123"
22+
23+
def test_safe_url(self):
24+
"""Test that safe_url validates and sanitizes URLs correctly."""
25+
# Test with safe URLs
26+
assert safe_url("http://example.com") == "http://example.com"
27+
assert safe_url("https://example.com") == "https://example.com"
28+
assert safe_url("/relative/path") == "/relative/path"
29+
assert safe_url("./relative/path") == "./relative/path"
30+
31+
# Test with unsafe URLs
32+
assert safe_url("javascript:alert(1)") is None
33+
assert safe_url("data:text/html,<script>alert(1)</script>") is None
34+
assert safe_url('vbscript:msgbox("Hello")') is None
35+
36+
# Test with None value
37+
assert safe_url(None) is None
38+
39+
def test_sanitize_dict(self):
40+
"""Test that sanitize_dict properly escapes dictionary values."""
41+
# Test with a simple dictionary
42+
data = {
43+
"name": "<strong>Test</strong>",
44+
"description": '<script>alert("XSS");</script>',
45+
"url": "http://example.com",
46+
"evil_url": "javascript:alert(1)",
47+
}
48+
49+
sanitized = sanitize_dict(data)
50+
51+
assert sanitized["name"] == "&lt;strong&gt;Test&lt;/strong&gt;"
52+
assert sanitized["description"] == "&lt;script&gt;alert(&quot;XSS&quot;);&lt;/script&gt;"
53+
assert sanitized["url"] == "http://example.com"
54+
assert sanitized["evil_url"] != "javascript:alert(1)"
55+
56+
# Test with nested dictionary
57+
nested_data = {
58+
"user": {"name": "<em>John</em>", "profile_url": "javascript:alert(2)"},
59+
"items": [{"name": "<b>Item 1</b>"}, {"name": "<script>alert(3)</script>"}],
60+
}
61+
62+
sanitized = sanitize_dict(nested_data)
63+
64+
assert sanitized["user"]["name"] == "&lt;em&gt;John&lt;/em&gt;"
65+
assert sanitized["user"]["profile_url"] != "javascript:alert(2)"
66+
assert sanitized["items"][0]["name"] == "&lt;b&gt;Item 1&lt;/b&gt;"
67+
assert sanitized["items"][1]["name"] == "&lt;script&gt;alert(3)&lt;/script&gt;"
68+
69+
70+
@pytest.mark.django_db
71+
class TestSecurityEscaping:
72+
"""Tests for security escaping in TomSelect widgets."""
73+
74+
@pytest.fixture
75+
def malicious_edition(self, sample_edition):
76+
"""Create an edition with malicious content."""
77+
original_name = sample_edition.name
78+
sample_edition.name = '<script>alert("XSS");</script>'
79+
sample_edition.save()
80+
81+
yield sample_edition
82+
83+
# Restore original name after test
84+
sample_edition.name = original_name
85+
sample_edition.save()
86+
87+
@pytest.fixture
88+
def edition_with_html(self, sample_edition):
89+
"""Create an edition with HTML tags in its name."""
90+
original_name = sample_edition.name
91+
sample_edition.name = '<strong>with formatting</strong> and <img src="x" onerror="alert(1)">'
92+
sample_edition.save()
93+
94+
yield sample_edition
95+
96+
# Restore original name after test
97+
sample_edition.name = original_name
98+
sample_edition.save()

example_project/test_widgets.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,3 +1919,147 @@ class MockView:
19191919
view2.value_fields.append(label_field)
19201920

19211921
assert "custom_field" in view2.value_fields
1922+
1923+
1924+
@pytest.mark.django_db
1925+
class TestWidgetSecurity:
1926+
"""Security tests for TomSelect widgets."""
1927+
1928+
@pytest.fixture
1929+
def malicious_edition(self):
1930+
"""Create an edition with various malicious content."""
1931+
edition = Edition.objects.create(
1932+
name='Attack Vector <img src="x" onerror="alert(1)">',
1933+
year="<script>document.cookie</script>",
1934+
pages='100" onmouseover="alert(2)',
1935+
pub_num="javascript:alert(3)",
1936+
)
1937+
yield edition
1938+
edition.delete()
1939+
1940+
@pytest.fixture
1941+
def setup_complex_widget(self):
1942+
"""Create widget with complex configuration for thorough testing."""
1943+
1944+
def _create_widget(show_urls=True, with_plugins=True):
1945+
config_kwargs = {
1946+
"url": "autocomplete-edition",
1947+
"value_field": "id",
1948+
"label_field": "name",
1949+
}
1950+
1951+
# Add URL display options if requested
1952+
if show_urls:
1953+
config_kwargs.update(
1954+
{
1955+
"show_detail": True,
1956+
"show_update": True,
1957+
"show_delete": True,
1958+
}
1959+
)
1960+
1961+
# Add plugin configurations if requested
1962+
if with_plugins:
1963+
config_kwargs.update(
1964+
{
1965+
"plugin_dropdown_header": PluginDropdownHeader(
1966+
show_value_field=True, extra_columns={"year": "Year", "pub_num": "Publication #"}
1967+
),
1968+
"plugin_dropdown_footer": PluginDropdownFooter(
1969+
title="Options",
1970+
),
1971+
"plugin_clear_button": PluginClearButton(),
1972+
"plugin_remove_button": PluginRemoveButton(),
1973+
}
1974+
)
1975+
1976+
return TomSelectModelWidget(config=TomSelectConfig(**config_kwargs))
1977+
1978+
return _create_widget
1979+
1980+
def test_render_option_template_escaping(self):
1981+
"""Test escaping in the option template for dropdown choices."""
1982+
# This test specifically targets the option.html template for search results
1983+
widget = TomSelectModelWidget(
1984+
config=TomSelectConfig(
1985+
url="autocomplete-edition",
1986+
value_field="id",
1987+
label_field="name",
1988+
plugin_dropdown_header=PluginDropdownHeader(show_value_field=True),
1989+
)
1990+
)
1991+
1992+
# Get the rendered widget and look for proper escaping in templates
1993+
full_render = widget.render("test_field", None)
1994+
1995+
# Check that the option template uses escape function
1996+
# We should find either template string syntax or function calls with escape
1997+
assert (
1998+
"${escape(data[this.settings.labelField])}" in full_render
1999+
or "${data[this.settings.labelField]}" in full_render
2000+
or "escape(data[this.settings.labelField])" in full_render
2001+
)
2002+
2003+
def test_javascript_url_escaping(self, setup_complex_widget, malicious_edition, monkeypatch):
2004+
"""Test that javascript: URLs are properly escaped in href attributes."""
2005+
widget = setup_complex_widget(show_urls=True)
2006+
2007+
# Mock methods to return javascript: URLs
2008+
def mock_url(view_name, args=None):
2009+
return "javascript:alert('hijacked');"
2010+
2011+
# Override the reverse function to return our malicious URLs
2012+
monkeypatch.setattr("django_tomselect.widgets.reverse", mock_url)
2013+
2014+
# Render the widget with a malicious edition
2015+
rendered = widget.render("test_field", malicious_edition.pk)
2016+
2017+
# Parse the output
2018+
soup = BeautifulSoup(rendered, "html.parser")
2019+
2020+
# Check that no unescaped javascript: URLs exist in href attributes
2021+
for a_tag in soup.find_all("a"):
2022+
if "href" in a_tag.attrs:
2023+
assert not a_tag["href"].startswith("javascript:")
2024+
2025+
# Check that script strings are properly escaped in the output
2026+
assert "javascript:alert('hijacked');" not in rendered
2027+
assert "javascript:" not in rendered or "javascript\\:" in rendered
2028+
2029+
def test_unsafe_object_properties(self, malicious_edition):
2030+
"""Test that unsafe object properties can't be exploited."""
2031+
# Create an object with properties that could be dangerous if not escaped
2032+
malicious_edition.name = "Safe Name"
2033+
malicious_edition.__proto__ = {"dangerous": "property"}
2034+
malicious_edition.constructor = {"dangerous": "constructor"}
2035+
malicious_edition.__defineGetter__ = lambda x: "exploit"
2036+
2037+
widget = TomSelectModelWidget(
2038+
config=TomSelectConfig(url="autocomplete-edition", value_field="id", label_field="name")
2039+
)
2040+
2041+
# This should not cause any errors or vulnerabilities
2042+
try:
2043+
rendered = widget.render("test_field", malicious_edition.pk)
2044+
# If we get here without error, that's good
2045+
assert rendered is not None
2046+
except Exception as e:
2047+
pytest.fail(f"Widget rendering failed with: {e}")
2048+
2049+
def test_dropdown_inputs_sanitization(self, setup_complex_widget):
2050+
"""Test that inputs in dropdown are properly sanitized."""
2051+
widget = setup_complex_widget(with_plugins=True)
2052+
2053+
# Add the dropdown_input plugin which renders an input in the dropdown
2054+
widget.plugin_dropdown_input = PluginDropdownInput()
2055+
2056+
# Render the widget (no need for a value)
2057+
rendered = widget.render("test_field", None)
2058+
2059+
# Look for proper escaping in the dropdown input elements
2060+
assert "escape(" in rendered
2061+
2062+
# Make sure event handlers on inputs are sanitized
2063+
assert 'input onkeyup="' not in rendered
2064+
assert 'input onchange="' not in rendered
2065+
assert 'input onfocus="' not in rendered

src/django_tomselect/autocompletes.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from django_tomselect.constants import EXCLUDEBY_VAR, FILTERBY_VAR, PAGE_VAR, SEARCH_VAR
1616
from django_tomselect.logging import package_logger
1717
from django_tomselect.models import EmptyModel
18+
from django_tomselect.utils import safe_url, sanitize_dict
1819

1920

2021
class AutocompleteModelView(View):
@@ -249,8 +250,6 @@ def prepare_results(self, results: QuerySet) -> list[dict[str, Any]]:
249250
pk_name = self.model._meta.pk.name
250251
for item in values:
251252
# Only include URLs if user has relevant permissions
252-
# item['can_list'] = self.has_permission(self.request, 'list')
253-
# item['can_create'] = self.has_permission(self.request, 'create')
254253
item["can_view"] = self.has_permission(self.request, "view")
255254
item["can_update"] = self.has_permission(self.request, "update")
256255
item["can_delete"] = self.has_permission(self.request, "delete")
@@ -261,22 +260,26 @@ def prepare_results(self, results: QuerySet) -> list[dict[str, Any]]:
261260
# Add instance-specific URLs conditionally based on permissions
262261
if self.detail_url and item["can_view"]:
263262
try:
264-
item["detail_url"] = reverse(self.detail_url, args=[item["id"]])
263+
item["detail_url"] = safe_url(reverse(self.detail_url, args=[item["id"]]))
265264
except NoReverseMatch:
266265
package_logger.warning("Could not reverse detail_url %s", self.detail_url)
267266

268267
if self.update_url and item["can_update"]:
269268
try:
270-
item["update_url"] = reverse(self.update_url, args=[item["id"]])
269+
item["update_url"] = safe_url(reverse(self.update_url, args=[item["id"]]))
271270
except NoReverseMatch:
272271
package_logger.warning("Could not reverse update_url %s", self.update_url)
273272

274273
if self.delete_url and item["can_delete"]:
275274
try:
276-
item["delete_url"] = reverse(self.delete_url, args=[item["id"]])
275+
item["delete_url"] = safe_url(reverse(self.delete_url, args=[item["id"]]))
277276
except NoReverseMatch:
278277
package_logger.warning("Could not reverse delete_url %s", self.delete_url)
279278

279+
# Sanitize all values to prevent XSS
280+
item = sanitize_dict(item)
281+
282+
# Allow custom processing through hook
280283
return self.hook_prepare_results(values)
281284

282285
def hook_prepare_results(self, results: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -314,7 +317,6 @@ def has_permission(self, request, action="view"):
314317
315318
Supports custom auth backends via Django's auth system.
316319
"""
317-
318320
# Skip all checks if configured to do so
319321
if self.skip_authorization:
320322
return True
@@ -459,7 +461,6 @@ def get_iterable(self) -> list[dict[str, str | int]]:
459461
return []
460462

461463
try:
462-
463464
# Handle TextChoices and IntegerChoices
464465
if isinstance(self.iterable, type) and hasattr(self.iterable, "choices"):
465466
return [

src/django_tomselect/templates/django_tomselect/render/clear_button.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
{% block code %}
77
html: function(data){
8-
return `<div class="${data.className}"
9-
title="${data.title}"
8+
return `<div class="${escape(data.className)}"
9+
title="${escape(data.title)}"
1010
role="button"
1111
aria-label="{% translate 'Clear selection' %}"
1212
tabindex="0">&times;</div>`;

src/django_tomselect/templates/django_tomselect/render/dropdown_footer.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
html: function (data) {
88
let footer = ''
99
footer += `
10-
<div title="{{ widget.plugins.dropdown_footer.title }}" class="{{ widget.plugins.dropdown_footer.footer_class }}">
10+
<div title="{{ widget.plugins.dropdown_footer.title|escapejs }}" class="{{ widget.plugins.dropdown_footer.footer_class }}">
1111
{% if "view_create_url" in widget.keys and widget.view_create_url %}
12-
<a href="{{ widget.view_create_url }}" title='{% translate "Go to Create View for these items" %}' class="{{ widget.plugins.dropdown_footer.create_view_class }}" target="_blank" rel="noopener noreferrer">{{ widget.plugins.dropdown_footer.create_view_label }}</a>
12+
<a href="{{ widget.view_create_url|escapejs }}" title='{% translate "Go to Create View for these items" %}' class="{{ widget.plugins.dropdown_footer.create_view_class }}" target="_blank" rel="noopener noreferrer">{{ widget.plugins.dropdown_footer.create_view_label|escapejs }}</a>
1313
{% endif %}
1414

1515
{% if "view_list_url" in widget.keys and widget.view_list_url %}
16-
<a href="{{ widget.view_list_url }}" title='{% translate "Go to List View for these items" %}' class="{{ widget.plugins.dropdown_footer.list_view_class }}" target="_blank" rel="noopener noreferrer">{{ widget.plugins.dropdown_footer.list_view_label }}</a>
16+
<a href="{{ widget.view_list_url|escapejs }}" title='{% translate "Go to List View for these items" %}' class="{{ widget.plugins.dropdown_footer.list_view_class }}" target="_blank" rel="noopener noreferrer">{{ widget.plugins.dropdown_footer.list_view_label|escapejs }}</a>
1717
{% endif %}
1818
</div>
1919
`

src/django_tomselect/templates/django_tomselect/render/dropdown_header.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,29 @@
99
{% if widget.plugins.dropdown_header.show_value_field %}
1010
header += `
1111
<div class="col">
12-
<span class="{{ widget.plugins.dropdown_header.label_class }}" role="columnheader">{{ widget.plugins.dropdown_header.value_field_label }}</span>
12+
<span class="{{ widget.plugins.dropdown_header.label_class }}" role="columnheader">{{ widget.plugins.dropdown_header.value_field_label|escapejs }}</span>
1313
</div>
1414
<div class="col">
15-
<span class="{{ widget.plugins.dropdown_header.label_class }}" role="columnheader">{{ widget.plugins.dropdown_header.label_field_label }}</span>
15+
<span class="{{ widget.plugins.dropdown_header.label_class }}" role="columnheader">{{ widget.plugins.dropdown_header.label_field_label|escapejs }}</span>
1616
</div>
1717
`
1818
{% else %}
1919
header += `
2020
<div class="col">
21-
<span class="{{ widget.plugins.dropdown_header.label_class }}" role="columnheader">{{ widget.plugins.dropdown_header.label_field_label }}</span>
21+
<span class="{{ widget.plugins.dropdown_header.label_class }}" role="columnheader">{{ widget.plugins.dropdown_header.label_field_label|escapejs }}</span>
2222
</div>
2323
`
2424
{% endif %}
2525

2626
{% for header in widget.plugins.dropdown_header.extra_headers %}
2727
header += `
2828
<div class="col">
29-
<span class="{{ widget.plugins.dropdown_header.label_class }}" role="columnheader">{{ header }}</span>
29+
<span class="{{ widget.plugins.dropdown_header.label_class }}" role="columnheader">{{ header|escapejs }}</span>
3030
</div>
3131
`
3232
{% endfor %}
3333

34-
return `<div class="{{ widget.plugins.dropdown_header.header_class }}" title="{{ widget.plugins.dropdown_header.title }}" role="row">
34+
return `<div class="{{ widget.plugins.dropdown_header.header_class }}" title="{{ widget.plugins.dropdown_header.title|escapejs }}" role="row">
3535
<div class="{{ widget.plugins.dropdown_header.title_row_class }}">${header}</div>
3636
</div>`
3737
},

0 commit comments

Comments
 (0)