Skip to content

Escape user-supplied titles reaching fast_html attributes (#888) - #922

Open
vjpixel wants to merge 2 commits into
developfrom
claude/fix-888-fast-html-escape
Open

vjpixel wants to merge 2 commits into
developfrom
claude/fix-888-fast-html-escape

Conversation

@vjpixel

@vjpixel vjpixel commented Apr 25, 2026 •

Copy link
Copy Markdown
Member

Description

Brings this up to date with develop (it had been conflicted since April) and adds the tests it was missing.

The issue it closes, #888, has been rewritten: the original text described sanitising a CommentForm that does not exist in this repository. The concern behind it turned out to be real and reachable, and worse than the issue claimed — a stored XSS, now documented with the exact lines.

Resolves (Issues)

Closes #888

The vector, for the reviewer

fast_html escapes nothing:

>>> render(img(src="x", title='" onerror="alert(1)'))
'``<img src="x" title="" onerror="alert(1)">``'

title is a plain user-supplied form field, it goes straight into that attribute in as_html(), and the modal templates render the result with | safe. Upload a marker titled " onerror="alert(1) and it runs in the browser of anyone who opens that marker's modal.

What the merge changed about this PR

develop has since deleted Sound.as_html_thumbnail and Exhibit.as_html_thumbnail, so the escaping this PR originally added to them no longer has anywhere to live — those hunks take develop's deletion. The one surviving conflict was in Marker.as_html, where develop introduced a cache-busted src; both sides are kept.

Net diff against current develop is therefore small and entirely the fix: the escape import, escape() at the three as_html attribute sites, and the | safe dropped from the blog excerpt.

Tests added

The original PR had none, which is how this bug comes back. src/core/tests/test_html_escaping.py:

  • a title of " onerror="alert(1) cannot break out of the attribute, on Marker, Object and Sound;
  • a <script> tag in a title is neutralised;
  • and a test asserting that fast_html still escapes nothing — so if that ever changes, someone finds out deliberately rather than discovering that the call-site escaping had quietly become redundant.

Removing the three escape() calls again makes 4 of the 5 fail.

Worth a follow-up, not done here

Escaping at each call site relies on every future as_html remembering to do it, and fast_html will keep not escaping. A single choke point returning SafeString would be sturdier — but that is a refactor, and this PR is a security fix that should merge on its own.

Verification: full suite pytest src/core src/users src/blog passes — 277 passed. ruff format --diff src/ clean, ruff check --extend-select I at 93 findings matching develop with zero I001, no pending migrations.


🤖 Generated with Claude Code

https://claude.ai/code/session_01HXUw7kyELDu8ycGQxtaFbb


Generated by Claude Code

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

vjpixel commented Apr 25, 2026

Copy link
Copy Markdown
Member Author

Self-review

Verdict: ✅

fast_html does not escape attribute values or text content — verified directly:

>>> render(span('<script>alert(1)</script>'))
'<span><script>alert(1)</script></span>'
>>> render(img(title='<script>alert(1)</script>', src='x'))
'<img title="<script>alert(1)</script>" src="x">'

Combined with | safe on every as_html()/as_html_thumbnail() consumer in jinja2, this was a real attribute-injection / inline-script XSS waiting on someone with a malicious title.

Scope:

  • Sound.as_html/as_html_thumbnail, Marker.as_html, Object.as_html, Exhibit.as_html_thumbnail — all user-controlled strings (title, name, slug, username) wrapped in escape().
  • post.excerpt was rendered with | safe in post_preview.jinja2 despite being a plain TextField — | safe removed.
  • post.formatted_body left alone — already sanitized by ProseEditor(sanitize=True).
  • used_in_html_string() and other internal compositions left alone — no user input.

Verified: escape() produces &lt;script&gt;...&lt;/script&gt; in both attribute and text positions, defusing the injection.

Unique: No duplicate PR.


Generated by Claude Code

Resolves the conflict this PR had accumulated since April.

develop has since deleted Sound.as_html_thumbnail and
Exhibit.as_html_thumbnail, so the escaping this PR added to them is moot and
those hunks take develop's deletion. The surviving conflict in Marker.as_html
combines both sides: develop's cache-busted `src` with this PR's
escape(self.title).

What remains is the whole of the fix on current develop: escape() at the
three as_html attribute sites, and the `| safe` dropped from the blog
excerpt.

Adds the tests the PR was missing. A security fix without one is how the bug
returns: they assert a title of `" onerror="alert(1)` cannot break out of the
attribute on Marker, Object and Sound, that a script tag is neutralised, and
— deliberately — that fast_html still escapes nothing, so the call-site
escaping is known to be load-bearing rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HXUw7kyELDu8ycGQxtaFbb
@vjpixel vjpixel changed the title Escape user-provided text in fast_html rendering (#888) Escape user-supplied titles reaching fast_html attributes (#888) Sep 17, 2026

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stored XSS: user-supplied titles reach fast_html attributes unescaped

2 participants