Skip to content

Stored XSS: user-supplied titles reach fast_html attributes unescaped #888

Description

@vjpixel

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)'))
'``&lt;img src="x" title="" onerror="alert(1)"&gt;``'

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

  1. Escape the values before they reach fast_html — django.utils.html.escape on title / name at each as_html / as_html_thumbnail call site.
  2. Drop the | safe from the blog excerpt.
  3. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions