GHSA-9395-2g46-rj3f: XSS
Five independent defects in djust's template auto-escaping cause attacker-controlled input to be rendered as live markup where Django escapes it. All four are present in shipped 1.1.0 and are fixed in 1.1.1.
They share one shape: a filter or grant that escapes nothing itself and relies on the render-time auto-escape, which something downstream then removes. They are grouped into a single advisory because the mitigation is identical — upgrade to 1.1.1 — and because no single one of them is meaningfully actionable in isolation.
1. linenumbers never escaped its input (#2291)
{{ p|linenumbers|safe }} with p = '<img src=x onerror=alert(1)>' djust '1. <img src=x onerror=alert(1)>' <- executes django '1. <img src=x onerror=alert(1)>'
The filter deferred all escaping to render time; a trailing |safe suppressed exactly that. The exposure is wider than the |safe form: any downstream filter that reads the output as markup is affected, including {{ p|linenumbers|truncatecharshtml:"5" }}, which contains no |safe at all.
2. escape was a no-op (#2281)
{{ p|escape|safe }} djust '<img src=x onerror=alert(1)>' <- executes django '&lt;img src=x onerror=alert(1)&gt;'
Django's escape is eager (conditionalescape, returning SafeString). djust's returned its input unchanged and let the render site escape it — indistinguishable for {{ p|escape }} alone, wrong for every chain. The security cell is {{ p|escape|safe }}: an idiom that reads as "escape it, then it is safe to emit" — which is what Django's semantics make true — was a bare |safe on attacker input. A sweep of every length-2 and length-3 chain containing escape found 104 live-markup cells.
3. unorderedlist / safeseq handed a string back under a safe grant (#2274)
Both carry an unconditional "emit without escaping" grant, earned because they escape every item they emit. Given a string rather than a sequence they emitted nothing and returned the input verbatim under that same grant, making {{ hostile|safeseq }} an exact synonym for |safe with no marksafe anywhere in the template.
4. A safety grant outlived the value it was granted for (#2300)
No filter chain and no |safe anywhere; a bare {{ p }} is the whole reproducer.
Safe context keys accumulated on the view and were never revoked, so a key marked safe once stayed safe for the lifetime of the view — which spans every event on a WebSocket connection:
python render 1: p = marksafe('<b>trusted</b>') -> '<b>trusted</b>' correct render 2: p = '<img src=x onerror=alert(1)>' -> executes
A view that renders trusted markup into a variable and later renders user input into the same variable emits it live.
5. A custom tag handler's return was emitted raw — including djust's own {% renderslot %} (#2379)
Reachable with no |safe, no marksafe, and no application code: using component slots is enough.
Django's SimpleNode.render runs conditionalescape over a simpletag's return unless it carries html. djust inserted the return verbatim, so a handler as ordinary as return f"Hello {name}" emitted attacker markup live.
Of the 221 handlers djust registers, one echoes a context value unescaped — renderslot, the framework's own function-component/slot tag:
{% renderslot p %} p = '<img src=x onerror=alert(1)>'
djust '<img src=x onerror=alert(1)>' <- executes django '<img src=x onerror=alert(1)>'
Together with defect 3 this is one of the two classes reachable without the application writing anything unusual.
6. linebreaks / linebreaksbr — and |safe was the only spelling that worked (#2284)
linebreaks emits <p>/<br> but neither escaped its content nor reported its output safe. The plain spelling therefore escaped the filter's own tags and printed a literal <p> on the page, so |safe was the only form that rendered at all — and that form emitted the content live:
{{ bio|linebreaks }} renders literal '<p>' text (visibly broken) {{ bio|linebreaks|safe }} '<img src=x onerror=alert(1)>' <- executes
Because the broken spelling is the one a developer discards, the vulnerable spelling is the one that ships. Any application rendering user-entered text with paragraph breaks is written that way.
Impact
Stored or reflected XSS in any djust application that renders untrusted input through the affected filters, or that reuses a context variable which was previously marked safe. Exploitation requires no special configuration. Defects 3, 4 and 5 require no unusual template construct at all — defect 5 needs only that the application use component slots, and defect 6's vulnerable spelling is the only one that renders correctly.
Patches
Fixed in 1.1.1, and in 1.2.0 (main).
1.1.1 re-implements each fix against 1.1.0's own code rather than back-porting main's, which depends on a value-level safety model 1.1.0 does not have. Three consequences are documented in the 1.1.1 CHANGELOG and are all in the over-escaping direction: {{ p|escape|F }} double-escapes for a plain following filter F; {% renderslot slot.content %} over-escapes; and a marksafed value passed through |escape is escaped rather than passed through.
Not fixed in 1.1.1, and tracked separately: application-written tag handlers that return attacker data as a plain str (only djust's own renderslot is covered by defect 5), and a {% with %}/{% for %} bind inheriting a safety grant it never earned. Both are fixed in 1.2.0.
Workarounds
None complete. Before upgrading, avoid |safe after any filter in a chain, avoid safeseq/unorderedlist on values that may be strings, and avoid reusing a context variable for both marksafe content and untrusted input.
Credit
Found during an internal Django-parity audit by a registry-wide differential that compares djust's escaping capabilities against Django's across every filter chain, rather than by inspection.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/djustto a version that resolves this vulnerability.Fixed in 1.1.1 - Upgrade
Upgrade to a fixed release to a version that resolves this vulnerability.
Fixed in 1.1.1 - Upgrade
Upgrade to a fixed release to a version that resolves this vulnerability.
Fixed in 1.2.0 - Configuration
Before upgrading, avoid using `|safe` after any filter in a chain.
djust template rendering Avoid using |safe after any filter in a chain = N/A - Configuration
Before upgrading, avoid using `safeseq`/`unordered_list` on values that may be strings.
djust template rendering Avoid safeseq/unordered_list on string-like values = N/A - Configuration
Before upgrading, avoid reusing a context variable for both `mark_safe` content and untrusted input.
djust application context handling Avoid reusing a context variable for both mark_safe content and untrusted input = N/A - Compensating control
If upgrading is not immediate, audit template usage to ensure downstream filters do not treat previously-rendered content as markup when that content originates from untrusted input (e.g., avoid constructions that effectively emit attacker-controlled markup without escaping).
Event History
Frequently Asked Questions
Which releases should be upgraded?
The advisory identifies issues in shipped djust 1.1.0 and states that the grouped mitigation is to upgrade to 1.1.1.
What template patterns make attacker-controlled values dangerous?
Values processed by filters that defer escaping to render time can become executable markup if a later operation removes or bypasses that auto-escaping. Documented examples include linenumbers followed by safe, escape followed by safe, and linenumbers followed by truncatechars_html:"5".
Is explicitly using the safe filter required for exploitation?
No. The advisory specifically notes that downstream filters which read output as markup can create exposure without a safe filter; truncatechars_html is given as an example.