Skip to content

Commit df6ade1

Browse files
committed
Fix rendering of analytics using Django safe strings
The previous solution, introduced when fixing GHSA-3jmp-r88c-34j9/CVE-2024-32405 used bulk escaping using bleach, which led to many unsightly rendered-as-text HTML elements.
1 parent fd11472 commit df6ade1

File tree

9 files changed

+35
-31
lines changed

9 files changed

+35
-31
lines changed

course/analytics.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -543,9 +543,6 @@ def page_analytics(pctx, flow_id, group_id, page_id):
543543
grading_page_context, visit.page_data.data, visit.answer)
544544
if normalized_answer is None:
545545
normalized_answer = _("(No answer)")
546-
else:
547-
import bleach
548-
normalized_answer = bleach.clean(normalized_answer)
549546

550547
answer_feedback = visit.get_most_recent_feedback()
551548

course/page/base.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -736,8 +736,10 @@ def normalized_answer(
736736
answer_data: Any
737737
) -> str | None:
738738
"""An HTML-formatted answer to be used for summarization and
739-
display in analytics. Since this may include user input, it is
740-
expected to be sanitized.
739+
display in analytics. By default, this is escaped when included
740+
in the output page, however it may use utilities such as
741+
:func:`~django.utils.safestring.mark_safe` to mark the output
742+
as safe and avoid escaping.
741743
"""
742744
return None
743745

course/page/choice.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from django.utils.html import format_html_join
4+
35

46
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
57

@@ -647,19 +649,15 @@ def grade(self, page_context, page_data, answer_data, grade_data):
647649
return AnswerFeedback(correctness=correctness)
648650

649651
def get_answer_html(self, page_context, idx_list, unpermute=False):
650-
answer_html_list = []
651652
if unpermute:
652653
idx_list = list(set(idx_list))
653-
for idx in idx_list:
654-
answer_html_list.append(
655-
"<li>"
656-
+ (self.process_choice_string(
654+
655+
return format_html_join(
656+
"\n", "{}",
657+
[(self.process_choice_string(
657658
page_context,
658-
self.choices[idx].text))
659-
+ "</li>"
660-
)
661-
answer_html = "<ul>"+"".join(answer_html_list)+"</ul>"
662-
return answer_html
659+
self.choices[idx].text),)
660+
for idx in idx_list])
663661

664662
def correct_answer(self, page_context, page_data, answer_data, grade_data):
665663
corr_idx_list = self.unpermuted_correct_indices()

course/page/code.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from django.utils.safestring import mark_safe
4+
35

46
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
57

@@ -1067,7 +1069,7 @@ def normalized_answer(self, page_context, page_data, answer_data):
10671069
normalized_answer = self.get_code_from_answer_data(answer_data)
10681070

10691071
from django.utils.html import escape
1070-
return f"<pre>{escape(normalized_answer)}</pre>"
1072+
return mark_safe(f"<pre>{escape(normalized_answer)}</pre>")
10711073

10721074
def normalized_bytes_answer(self, page_context, page_data, answer_data):
10731075
if answer_data is None:

course/page/inline.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from django.utils.html import format_html_join
4+
35

46
__copyright__ = "Copyright (C) 2015 Andreas Kloeckner, Dong Zhuang"
57

@@ -347,8 +349,7 @@ def get_width_str(self, opt_width=0):
347349
return "width: " + str(max(self.width, opt_width)) + "em"
348350

349351
def get_answer_text(self, page_context, answer):
350-
from django.utils.html import escape
351-
return escape(answer)
352+
return answer
352353

353354
def get_correct_answer_text(self, page_context):
354355

@@ -472,8 +473,9 @@ def __init__(self, vctx, location, name, answers_desc):
472473
def get_answer_text(self, page_context, answer):
473474
if answer == "":
474475
return answer
475-
return self.process_choice_string(
476-
page_context, self.answers_desc.choices[int(answer)])
476+
return mark_safe(
477+
self.process_choice_string(
478+
page_context, self.answers_desc.choices[int(answer)]))
477479

478480
def get_width_str(self, opt_width=0):
479481
return None
@@ -979,9 +981,9 @@ def normalized_answer(self, page_context, page_data, answer_data):
979981

980982
answer_dict = answer_data["answer"]
981983

982-
return ", ".join(
983-
[self.answer_instance_list[idx].get_answer_text(
984-
page_context, answer_dict[self.embedded_name_list[idx]])
984+
return format_html_join(", ", "{}",
985+
[(self.answer_instance_list[idx].get_answer_text(
986+
page_context, answer_dict[self.embedded_name_list[idx]]),)
985987
for idx, name in enumerate(self.embedded_name_list)]
986988
)
987989

course/page/text.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -766,8 +766,7 @@ def normalized_answer(self, page_context, page_data, answer_data):
766766
if not self._is_case_sensitive():
767767
normalized_answer = normalized_answer.lower()
768768

769-
from django.utils.html import escape
770-
return escape(normalized_answer)
769+
return normalized_answer
771770

772771
def normalized_bytes_answer(self, page_context, page_data, answer_data):
773772
if answer_data is None:

course/templates/course/analytics-page.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ <h2>{% trans "Answer Distribution" %}</h2>
3838
<div class="answer-analytics">
3939
{% for astats in answer_stats_list %}
4040
<div class="answer-entry">
41-
{{ astats.normalized_answer|safe }}
41+
{{ astats.normalized_answer }}
4242
{% blocktrans trimmed with astats_count=astats.count count counter=astats.count %}
4343
({{ astats_count }} response)
4444
{% plural %}

frontend/css/style.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ label div.relate-markup p:only-child {
118118
margin-bottom: 0;
119119
}
120120

121+
/* remove extra spacing from ChoiceQuestion options in analytics */
122+
div.answer-analytics div.relate-markup p:only-child {
123+
margin-bottom: 0;
124+
}
125+
121126
/* }}} */
122127

123128
/* {{{ histograms */

tests/test_pages/test_choice.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -439,12 +439,11 @@ def test_choice_item_with_always_correct(self):
439439
self.assertResponseContextEqual(
440440
resp, "correct_answer",
441441
"The correct answer is: "
442-
"<ul><li><div class='relate-markup'><p>Sprinkles</p></div></li>"
443-
"<li><div class='relate-markup'><p>Chocolate chunks</p></div></li>"
444-
"<li><div class='relate-markup'><p>Almond bits</p></div></li>"
445-
"</ul>"
442+
"<div class='relate-markup'><p>Sprinkles</p></div>\n"
443+
"<div class='relate-markup'><p>Chocolate chunks</p></div>\n"
444+
"<div class='relate-markup'><p>Almond bits</p></div>"
446445
"Additional acceptable options are: "
447-
"<ul><li><div class='relate-markup'><p>A flawed option</p></div></li></ul>")
446+
"<div class='relate-markup'><p>A flawed option</p></div>")
448447

449448
# }}}
450449

0 commit comments

Comments
 (0)