diff --git a/src/blog/jinja2/blog/post_preview.jinja2 b/src/blog/jinja2/blog/post_preview.jinja2 index 49b4ffb7..1968b972 100644 --- a/src/blog/jinja2/blog/post_preview.jinja2 +++ b/src/blog/jinja2/blog/post_preview.jinja2 @@ -4,7 +4,7 @@ {{ post.title }}
- {{ post.excerpt[:PREVIEW_SIZE] | safe }}... {{ _("Read More") }} + {{ post.excerpt[:PREVIEW_SIZE] }}... {{ _("Read More") }}
{% endfor %} diff --git a/src/core/models.py b/src/core/models.py index 49628392..3f5d9e4e 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -6,6 +6,7 @@ from django.db.models.signals import m2m_changed, post_delete, post_save, pre_save from django.dispatch import receiver from django.urls import reverse +from django.utils.html import escape from django.utils.translation import gettext_lazy as _ from django_extensions.db.models import TimeStampedModel from fast_html import a, audio, img, render, video @@ -120,7 +121,7 @@ def used_in_html_string(self): def as_html(self): attributes = { "id": self.id, - "title": self.title, + "title": escape(self.title), "src": self.file.url, } return render( @@ -173,7 +174,7 @@ def as_html( src = image.url + f"?v={int(self.modified.timestamp())}" attributes = { "id": self.id, - "title": self.title, + "title": escape(self.title), "src": src, } return render( @@ -390,7 +391,7 @@ def is_3d(self): def as_html(self, height: int = None, width: int = None): attributes = { "id": self.id, - "title": self.title, + "title": escape(self.title), "src": self.source.url, } max_w = width if width else DEFAULT_OBJECT_PREVIEW_WIDTH diff --git a/src/core/tests/test_html_escaping.py b/src/core/tests/test_html_escaping.py new file mode 100644 index 00000000..82f5454e --- /dev/null +++ b/src/core/tests/test_html_escaping.py @@ -0,0 +1,51 @@ +"""Guard the escaping of user-supplied text rendered through fast_html. + +`fast_html` escapes nothing, and the modal templates render its output with +`| safe`, so anything reaching an attribute unescaped is a stored XSS. See #888. +""" + +from django.test import TestCase + +from core.tests.factory import MarkerFactory, ObjectFactory, SoundFactory + +BREAKOUT = '" onerror="alert(1)' + + +class TestFastHtmlEscaping(TestCase): + def test_fast_html_still_does_not_escape(self): + """If this ever fails, fast_html started escaping and the call-site + escaping below could be reconsidered. Until then it is load-bearing.""" + from fast_html import img, render + + assert render(img(src="x", title=BREAKOUT)) == ( + f'