Description
This issue was rewritten. The original text described sanitising a CommentForm in src/core/forms.py — that form does not exist and never has, so the issue as filed could not be acted on. But the concern behind it turned out to be real, reachable, and worse than described: there is a stored XSS in the content modals. This is the verified version.
Location
Files: src/core/models.py, src/core/jinja2/core/templates/*_modal.jinja2, src/blog/jinja2/blog/post_preview.jinja2
Branch: develop
The vector
fast_html does no escaping at all. Verified against the installed version:
>>> render(span('<script>alert(1)</script>'))
'<span><script>alert(1)</script></span>'
>>> render(img(src="x", title='" onerror="alert(1)'))
'``<img src="x" title="" onerror="alert(1)">``'
The second line is the problem: a " in the value closes the attribute and everything after it becomes markup.
Marker.as_html(), Object.as_html() and Sound.as_html() put the model's title straight into that attribute (src/core/models.py:123, :176, :393):
attributes = {
"id": self.id,
"title": self.title,
"src": self.source.url,
}
title is a plain user-supplied form field — UploadMarkerForm.Meta.fields and UploadObjectForm.Meta.fields both include it, with no validation on its contents.
And the templates render that output with autoescaping explicitly switched off:
{{ marker.as_html() | safe }}
— in marker_modal.jinja2:20, object_modal.jinja2:73, sound_modal.jinja2:20 and twice in artwork_modal.jinja2.
So: upload a marker titled " onerror="alert(1), and the payload runs in the browser of anyone who opens that marker's modal — including other users browsing the shared collection.
A second, independent instance
src/blog/jinja2/blog/post_preview.jinja2:7 renders the post excerpt through | safe:
{{ post.excerpt[:PREVIEW_SIZE] | safe }}...
Different input path (editor-authored rather than any user), same bypass of autoescaping. Worth fixing in the same pass.
Suggested Fix
- Escape the values before they reach
fast_html — django.utils.html.escape on title / name at each as_html / as_html_thumbnail call site.
- Drop the
| safe from the blog excerpt.
- Consider whether these
as_html helpers should return SafeString from a single escaping choke point instead of relying on every call site remembering, since fast_html will keep not escaping.
There is already an open PR for this — #922, from April — which does 1 and 2. It is currently conflicted against develop and needs bringing up to date; develop today has three unescaped title sites and the excerpt | safe is still there.
Severity
High. Stored, reachable by other users, and needs nothing but an upload form. The original "Medium" rating belonged to the imaginary version of this issue.
Generated by Claude Code
Description
This issue was rewritten. The original text described sanitising a
CommentForminsrc/core/forms.py— that form does not exist and never has, so the issue as filed could not be acted on. But the concern behind it turned out to be real, reachable, and worse than described: there is a stored XSS in the content modals. This is the verified version.Location
Files:
src/core/models.py,src/core/jinja2/core/templates/*_modal.jinja2,src/blog/jinja2/blog/post_preview.jinja2Branch:
developThe vector
fast_htmldoes no escaping at all. Verified against the installed version:The second line is the problem: a
"in the value closes the attribute and everything after it becomes markup.Marker.as_html(),Object.as_html()andSound.as_html()put the model'stitlestraight into that attribute (src/core/models.py:123,:176,:393):titleis a plain user-supplied form field —UploadMarkerForm.Meta.fieldsandUploadObjectForm.Meta.fieldsboth include it, with no validation on its contents.And the templates render that output with autoescaping explicitly switched off:
{{ marker.as_html() | safe }}— in
marker_modal.jinja2:20,object_modal.jinja2:73,sound_modal.jinja2:20and twice inartwork_modal.jinja2.So: upload a marker titled
" onerror="alert(1), and the payload runs in the browser of anyone who opens that marker's modal — including other users browsing the shared collection.A second, independent instance
src/blog/jinja2/blog/post_preview.jinja2:7renders the post excerpt through| safe:{{ post.excerpt[:PREVIEW_SIZE] | safe }}...Different input path (editor-authored rather than any user), same bypass of autoescaping. Worth fixing in the same pass.
Suggested Fix
fast_html—django.utils.html.escapeontitle/nameat eachas_html/as_html_thumbnailcall site.| safefrom the blog excerpt.as_htmlhelpers should returnSafeStringfrom a single escaping choke point instead of relying on every call site remembering, sincefast_htmlwill keep not escaping.There is already an open PR for this — #922, from April — which does 1 and 2. It is currently conflicted against
developand needs bringing up to date;developtoday has three unescapedtitlesites and the excerpt| safeis still there.Severity
High. Stored, reachable by other users, and needs nothing but an upload form. The original "Medium" rating belonged to the imaginary version of this issue.
Generated by Claude Code