Skip to content

Commit 180cbf2

Browse files
committed
Implement HTML sanitization for student and teacher messages
Fixes #132
1 parent b6d8873 commit 180cbf2

6 files changed

Lines changed: 171 additions & 7 deletions

File tree

dynamic_forms/static/dynamic_forms.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ function dynamic_forms_textarea() {
1313
}
1414
textbox.hide();
1515
span = $('<span class="textarea"></span>');
16-
span.html(base.val() || blankResponse);
16+
var rawVal = base.val();
17+
var sanitizerFnName = base.data('html-sanitizer');
18+
if (sanitizerFnName && typeof window[sanitizerFnName] === 'function' && rawVal) {
19+
span.html(window[sanitizerFnName](rawVal));
20+
} else {
21+
span.html(rawVal || blankResponse);
22+
}
1723
if (base.data('spantarget')) {
1824
spanbox = $(base.data('spantarget')).first();
1925
spanbox.show();

feedback/static/feedback.js

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,63 @@ $(function() {
9191
});
9292
}
9393

94+
/* Sanitize response message HTML: keep allowed tags, escape everything else.
95+
* Must mirror the server-side sanitize_response_message() helper in feedback/utils.py. */
96+
const _ALLOWED_RESPONSE_TAGS = new Set(['B', 'I', 'U', 'STRONG', 'EM', 'CODE', 'A', 'BR']);
97+
const _ALLOWED_RESPONSE_ATTRS = new Map([['A', new Set(['href', 'title'])]]);
98+
const _SAFE_URL_SCHEMES = new Set(['http:', 'https:', 'mailto:']);
99+
100+
function _sanitizeResponseNode(source, target) {
101+
for (const child of source.childNodes) {
102+
if (child.nodeType === Node.TEXT_NODE) {
103+
target.appendChild(document.createTextNode(child.data));
104+
} else if (child.nodeType === Node.ELEMENT_NODE) {
105+
const tag = child.tagName; // uppercase in HTML documents
106+
if (_ALLOWED_RESPONSE_TAGS.has(tag)) {
107+
const el = document.createElement(tag.toLowerCase());
108+
const allowedAttrs = _ALLOWED_RESPONSE_ATTRS.get(tag) || new Set();
109+
for (const attrName of allowedAttrs) {
110+
const val = child.getAttribute(attrName);
111+
if (val === null) continue;
112+
if (attrName === 'href') {
113+
try {
114+
const parsed = new URL(val, window.location.href);
115+
if (!_SAFE_URL_SCHEMES.has(parsed.protocol)) continue;
116+
} catch (_) { continue; }
117+
}
118+
el.setAttribute(attrName, val);
119+
}
120+
_sanitizeResponseNode(child, el);
121+
target.appendChild(el);
122+
} else {
123+
// Render the unknown tag as literal text so it is visible
124+
let openTag = '<' + child.tagName.toLowerCase();
125+
for (const attr of child.attributes) {
126+
openTag += ' ' + attr.name + '="' + attr.value
127+
.replace(/&/g, '&amp;').replace(/"/g, '&quot;') + '"';
128+
}
129+
openTag += '>';
130+
target.appendChild(document.createTextNode(openTag));
131+
// Still process children rather than discarding them
132+
_sanitizeResponseNode(child, target);
133+
if (child.childNodes.length > 0) {
134+
target.appendChild(document.createTextNode('</' + child.tagName.toLowerCase() + '>'));
135+
}
136+
}
137+
}
138+
}
139+
}
140+
141+
function sanitizeResponseHTML(text) {
142+
const tmp = document.createElement('div');
143+
tmp.innerHTML = text;
144+
const out = document.createElement('div');
145+
_sanitizeResponseNode(tmp, out);
146+
return out.innerHTML; // safe HTML string with disallowed tags escaped
147+
}
148+
// Expose globally so dynamic_forms.js can look it up via data-html-sanitizer
149+
window.sanitizeResponseHTML = sanitizeResponseHTML;
150+
94151
/* Buttons for toggling preview state */
95152
let sStart, sEnd;
96153
function on_preview_button(e) {
@@ -101,7 +158,10 @@ $(function() {
101158
sStart = this.selectionStart;
102159
sEnd = this.selectionEnd;
103160
ta.hide();
104-
ta.after('<span class="textarea preview">' + ta.val() + '</span>');
161+
const previewSpan = document.createElement('span');
162+
previewSpan.className = 'textarea preview';
163+
previewSpan.innerHTML = sanitizeResponseHTML(ta.val());
164+
ta.after(previewSpan);
105165
});
106166
me.hide();
107167
me.siblings('.unpreview-button').show().focus();
@@ -717,11 +777,11 @@ async function studentDiscussionPreview(btn) {
717777
/* clean up response message, inject text into div rather than embedding form */
718778
const response_msgs = contentDiv.querySelectorAll('.response-message');
719779
for (const rsp_msg of response_msgs) {
720-
const text_content = rsp_msg.querySelector('textarea').innerText;
780+
const text_content = rsp_msg.querySelector('textarea').value;
721781
if (text_content) {
722782
const textDiv = document.createElement('div');
723783
textDiv.className = 'display-response';
724-
textDiv.innerText = text_content;
784+
textDiv.innerHTML = sanitizeResponseHTML(text_content);
725785
rsp_msg.firstElementChild.replaceWith(textDiv); // replace form with text div
726786
} else { // no text content, so don't display anything
727787
rsp_msg.remove();

feedback/templates/manage/_response_message.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
name="response_msg"
6161
data-texttarget="#{{ form.auto_id|fill_format_string:'edit_box' }}"
6262
data-spantarget="#{{ form.auto_id|fill_format_string:'span_box' }}"
63+
data-html-sanitizer="sanitizeResponseHTML"
6364
placeholder="{{ f.label }}"
6465
autocomplete="off"
6566
{{ form.disabled|yesno:'disabled,' }}

feedback/templates_j2/feedback/_form.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
{% if message %}
3636
<li class="list-group-item list-group-item-info staff{% if index > 2 %} initially-hidden{% endif %}">
3737
{{ glyph("mortarboard-fill", _("teacher")) }}
38-
<span class="text">{{ message|safe }}</span>
38+
<span class="text">{{ message|sanitize_response }}</span>
3939
</li>
4040
{% endif %}
4141
{%- endmacro -%}
@@ -49,7 +49,7 @@
4949
{%- set val = pair[1] -%}
5050
{%- if k == f_key and val -%}
5151
<li class="list-group-item student{% if index > 2 %} initially-hidden{% endif %}">
52-
<span class="text">{{ val|safe }}</span>
52+
<span class="text">{{ val }}</span>
5353
</li>
5454
{%- endif -%}
5555
{%- endfor -%}

feedback/templatetags/jinja_tests.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22
from jinja2 import pass_context
33

44
from dynamic_forms.fields import EnchantedBoundField
5+
from feedback.utils import sanitize_response_message
6+
7+
8+
@library.filter(name="sanitize_response")
9+
def sanitize_response_filter(value):
10+
"""Sanitize a teacher response message for safe HTML rendering.
11+
12+
Allows formatting tags (b, i, u, strong, em, code, a, br) and
13+
escapes everything else so that text like ``<foo>`` renders literally.
14+
"""
15+
return sanitize_response_message(value)
516

617

718
@library.test(name="has_textfields")

feedback/utils.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,99 @@
11
import logging
2-
from urllib.parse import urljoin, urlencode
2+
from html import escape as html_escape
3+
from html.parser import HTMLParser
4+
from urllib.parse import urljoin, urlencode, urlparse
5+
36
from django.conf import settings
47
from django.urls import reverse
58
from django.template.loader import get_template
69
from django.utils import translation
10+
from django.utils.safestring import mark_safe
711

812
from aplus_client.client import AplusGraderClient
913

1014

15+
# HTML tags allowed in teacher response messages (inserted by styling buttons)
16+
_ALLOWED_RESPONSE_TAGS = frozenset(['b', 'i', 'u', 'strong', 'em', 'code', 'a', 'br'])
17+
# Allowed attributes per tag; only 'href' and 'title' on <a>
18+
_ALLOWED_RESPONSE_ATTRS = {
19+
'a': frozenset(['href', 'title']),
20+
}
21+
# URL schemes permitted in href values
22+
_SAFE_URL_SCHEMES = frozenset(['http', 'https', 'mailto'])
23+
24+
25+
class _ResponseMessageSanitizer(HTMLParser):
26+
"""HTML parser that passes through a whitelist of tags/attributes and
27+
HTML-escapes everything else, so that text like ``<foo>`` is rendered
28+
literally rather than silently disappearing in the browser."""
29+
30+
def __init__(self):
31+
super().__init__(convert_charrefs=False)
32+
self._output = []
33+
34+
def handle_starttag(self, tag, attrs):
35+
if tag in _ALLOWED_RESPONSE_TAGS:
36+
allowed_attr_names = _ALLOWED_RESPONSE_ATTRS.get(tag, frozenset())
37+
safe_attrs = []
38+
for name, value in attrs:
39+
if name not in allowed_attr_names:
40+
continue
41+
if value is None:
42+
safe_attrs.append(html_escape(name))
43+
continue
44+
if name == 'href':
45+
scheme = urlparse(value).scheme.lower()
46+
if scheme and scheme not in _SAFE_URL_SCHEMES:
47+
continue
48+
safe_attrs.append('{}="{}"'.format(html_escape(name), html_escape(value)))
49+
attr_str = (' ' + ' '.join(safe_attrs)) if safe_attrs else ''
50+
self._output.append('<{}{}>'.format(tag, attr_str))
51+
else:
52+
attrs_str = ''
53+
for name, value in attrs:
54+
if value is None:
55+
attrs_str += ' {}'.format(name)
56+
else:
57+
attrs_str += ' {}="{}"'.format(name, value)
58+
self._output.append(html_escape('<{}{}>'.format(tag, attrs_str)))
59+
60+
def handle_endtag(self, tag):
61+
if tag in _ALLOWED_RESPONSE_TAGS:
62+
self._output.append('</{}>'.format(tag))
63+
else:
64+
self._output.append(html_escape('</{}>'.format(tag)))
65+
66+
def handle_data(self, data):
67+
self._output.append(html_escape(data))
68+
69+
def handle_entityref(self, name):
70+
self._output.append('&{};'.format(name))
71+
72+
def handle_charref(self, name):
73+
self._output.append('&#{};'.format(name))
74+
75+
def get_output(self):
76+
return ''.join(self._output)
77+
78+
79+
def sanitize_response_message(message):
80+
"""Sanitize an HTML teacher response message.
81+
82+
Allows a small whitelist of safe formatting tags (``<b>``, ``<i>``,
83+
``<u>``, ``<strong>``, ``<em>``, ``<code>``, ``<a>``, ``<br>``) and
84+
HTML-escapes everything else so that text like ``<foo>`` is displayed
85+
as the literal characters ``<foo>`` instead of vanishing.
86+
87+
Returns a :class:`~django.utils.safestring.SafeString` ready for
88+
insertion into a template without further escaping.
89+
"""
90+
if not message:
91+
return mark_safe('')
92+
parser = _ResponseMessageSanitizer()
93+
parser.feed(message)
94+
return mark_safe(parser.get_output())
95+
96+
1197
logger = logging.getLogger("feedback.utils")
1298

1399

0 commit comments

Comments
 (0)