From 6256025957b0f5324de9cd52d3c66ac4fb9b7ecd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 01:31:39 +0000 Subject: [PATCH] Escape user-provided text in fast_html rendering (#888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fast_html does not escape text content or attribute values, and every as_html() / as_html_thumbnail() output is rendered with | safe in the jinja2 templates. User-controlled fields (title, author, name, slug, username) could therefore inject HTML/JS. Wrap user strings with django.utils.html.escape before passing them to fast_html on Sound, Marker, Object, and Exhibit. Also remove the | safe filter from post.excerpt in post_preview.jinja2 — excerpt is a plain TextField, so default jinja autoescaping is the correct behavior (post.formatted_body is separately sanitized by ProseEditor). Closes #888 https://claude.ai/code/session_01XC1THLWgnGXGf5wgRhdyvB --- src/blog/jinja2/blog/post_preview.jinja2 | 2 +- src/core/models.py | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) 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 7406e2ae..09dd92a1 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -6,6 +6,7 @@ from django.db.models.signals import post_delete 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, b, div, h1, img, p, render, span, video @@ -178,7 +179,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( @@ -190,7 +191,7 @@ def as_html(self): def as_html_thumbnail(self, editable=False): elements = [ - span(self.title, style="display:block;"), + span(escape(self.title), style="display:block;"), self.as_html(), ] if editable and not self.is_used_by_other_user(): @@ -247,7 +248,7 @@ def is_used_by_other_user(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, } return render( @@ -364,7 +365,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, } if height: @@ -541,7 +542,9 @@ def content_type(self): def as_html_thumbnail(self, editable=False): link_to_exhibit = reverse("exhibit-detail", query={"id": self.id}) - exhibit_title = a(h1(self.name, class_="exhibit-name"), href=link_to_exhibit) + exhibit_title = a( + h1(escape(self.name), class_="exhibit-name"), href=link_to_exhibit + ) media_stats = [] if self.exhibit_type == ExhibitTypes.AR: media_stats.append( @@ -573,14 +576,17 @@ def as_html_thumbnail(self, editable=False): ) ) exhibit_info = [ - p([{_("Created by ")}, b(self.owner.user.username)], class_="by"), + p( + [{_("Created by ")}, b(escape(self.owner.user.username))], + class_="by", + ), p(self.date, class_="exbDate"), div(media_stats), ] button_see_this_exhibit = a( _("See this Exhibition"), - href=f"/{self.slug}/", + href=f"/{escape(self.slug)}/", class_="gotoExb", )