Skip to content

Commit 1f604a8

Browse files
committed
[fix] Make sure to HTML escape all dashboard labels
(cherry picked from commit 746b146)
1 parent dbbb4ba commit 1f604a8

4 files changed

Lines changed: 56 additions & 3 deletions

File tree

openwisp_utils/admin_theme/dashboard.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import copy
2+
import html
23

34
from django.core.exceptions import ImproperlyConfigured
45
from django.db.models import Count
@@ -126,7 +127,12 @@ def get_dashboard_context(request):
126127
group_by = query_params.get('group_by')
127128
annotate = query_params.get('annotate')
128129
aggregate = query_params.get('aggregate')
130+
129131
labels_i18n = value.get('labels')
132+
# HTML escape labels defined in configuration to prevent breaking the JS
133+
if labels_i18n:
134+
for label_key, label_value in labels_i18n.items():
135+
labels_i18n[label_key] = html.escape(label_value)
130136

131137
try:
132138
model = load_model(app_label, model_name)
@@ -175,9 +181,13 @@ def get_dashboard_context(request):
175181
if labels_i18n and qs_key in labels_i18n:
176182
# store original label as filter, but only
177183
# if we have more than the empty default label defined
178-
# if len(labels_i18n.keys()) > 1
179184
filters.append(label)
180185
label = labels_i18n[qs_key]
186+
else:
187+
# HTML escape labels coming from values in the DB
188+
# to avoid possible XSS attacks caused by
189+
# malicious DB values set by users
190+
label = html.escape(label)
181191
labels.append(label)
182192
# use predefined colors if available,
183193
# otherwise the JS lib will choose automatically

tests/test_project/apps.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ def register_dashboard_charts(self):
8181
'without_operator__sum': '#353c44',
8282
},
8383
'labels': {
84-
'with_operator__sum': _('Projects with operators'),
84+
# the <strong> is for testing purposes to
85+
# verify it's being HTML escaped correctly
86+
'with_operator__sum': _('<strong>Projects with operators</strong>'),
8587
'without_operator__sum': _('Projects without operators'),
8688
},
8789
'filters': {

tests/test_project/tests/test_dashboard.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
unregister_dashboard_chart,
1212
unregister_dashboard_template,
1313
)
14+
from openwisp_utils.admin_theme.dashboard import get_dashboard_context
1415

15-
from ..models import Project
16+
from ..models import Operator, Project
1617
from . import AdminTestMixin
18+
from .utils import MockRequest, MockUser
1719

1820

1921
class TestDashboardSchema(UnitTestCase):
@@ -178,3 +180,26 @@ def test_dashboard_disabled(self):
178180
with self.subTest('Test "Dashboard" is absent from menu items'):
179181
response = self.client.get(reverse('admin:index'))
180182
self.assertNotContains(response, 'Dashboard')
183+
184+
def test_get_dashboard_context_html_escape(self):
185+
# craft malicious DB value which will be shown in labels
186+
project = Project.objects.create(name='<script>alert(1)</script>')
187+
Operator.objects.create(project=project, first_name='xss', last_name='xss')
188+
# prepare mock request and get context
189+
mocked_user = MockUser(is_superuser=True)
190+
mocked_request = MockRequest(user=mocked_user)
191+
context = get_dashboard_context(mocked_request)
192+
# ensure DB value is escaped
193+
self.assertEqual(
194+
context['dashboard_charts'][0]['query_params']['labels'][0],
195+
'&lt;script&gt;alert(1)&lt;/script&gt;',
196+
)
197+
# ensure configured labels are escaped
198+
self.assertEqual(
199+
context['dashboard_charts'][1]['labels']['with_operator__sum'],
200+
'&lt;strong&gt;Projects with operators&lt;/strong&gt;',
201+
)
202+
self.assertEqual(
203+
context['dashboard_charts'][1]['query_params']['labels'][0],
204+
'&lt;strong&gt;Projects with operators&lt;/strong&gt;',
205+
)

tests/test_project/tests/utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import json
22
import os
3+
import uuid
34

45
from django.conf import settings
56
from django.contrib.auth import get_user_model
7+
from django.contrib.auth.models import AnonymousUser
68
from selenium import webdriver
79
from selenium.common.exceptions import NoSuchElementException
810
from selenium.webdriver.common.by import By
@@ -267,3 +269,17 @@ def wait_for_dropdown(self, filter_class):
267269
(By.CSS_SELECTOR, f'.{filter_class} .filter-options')
268270
)
269271
)
272+
273+
274+
class MockUser:
275+
def __init__(self, is_superuser=False):
276+
self.is_superuser = is_superuser
277+
self.id = uuid.uuid4()
278+
279+
280+
class MockRequest:
281+
def __init__(self, user=None):
282+
if user:
283+
self.user = user
284+
else:
285+
self.user = AnonymousUser()

0 commit comments

Comments
 (0)